A real-time, energy-aware, autonomous planetary rover navigation framework combining YOLOv8-based object detection, multi-factor scientific utility scoring, A*-driven terrain-adaptive path planning, and finite-state machine control — fully operable from a single monocular ESP32-CAM stream.
Architecture · Modules · Installation · Usage · Benchmarks · Configuration · API Reference
- Project Overview
- Key Contributions
- System Architecture
- Mathematical Foundation
- Module Reference
- 5.1 Configuration (
config.py) - 5.2 Energy Model (
energy_model.py) - 5.3 Decision Engine (
decision_engine.py) - 5.4 Rover FSM (
rover_fsm.py) - 5.5 Terrain Classifier (
terrain_classifier.py) - 5.6 Obstacle Detector (
obstacle_detector.py) - 5.7 Path Planner (
path_planner.py) - 5.8 Science Scorer (
science_scorer.py) - 5.9 Distance Estimator (
distance_estimator.py) - 5.10 Solar Estimator (
solar_estimator.py) - 5.11 Map Builder (
map_builder.py) - 5.12 Telemetry Logger (
telemetry_logger.py) - 5.13 Target Tracker (
target_tracker.py) - 5.14 Main Integration (
main.py) - 5.15 Benchmark (
benchmark.py)
- 5.1 Configuration (
- Data Flow & Frame Processing Pipeline
- Finite State Machine Design
- Terrain Analysis & Traversability Grid
- A* Path Planning on Traversability Grid
- Monocular Distance Estimation
- Solar Irradiance Modelling (Mars Environment)
- Telemetry & Downlink Format
- Benchmark Results
- Installation
- Usage
- Configuration Reference
- Project File Structure
- Dataset Preparation & Model Training
- Hardware Integration
- Known Limitations & Future Work
- Contributing
- Citation
- License
PerceptaNav is a complete, production-ready autonomous navigation system designed for planetary rover applications — specifically targeting Martian surface operations. It fuses real-time computer vision, energy-aware decision-making, terrain traversability analysis, and scientific value maximisation into a unified software stack that runs entirely from a single monocular camera stream.
Unlike conventional rover navigation systems that optimise purely for reachability or proximity, PerceptaNav introduces the concept of scientific utility — the ratio of scientific reward to energy expenditure — as its primary optimisation objective. The rover does not simply move to the nearest rock; it evaluates every visible candidate in the scene, computes a multi-factor scientific relevance score, projects the energy cost of reaching and sampling each candidate, and selects the target that maximises scientific return per watt-hour consumed.
The system is entirely sensor-agnostic in its core design: it requires no depth sensor, no IMU, no GPS, and no pre-loaded map. Everything — terrain classification, obstacle detection, distance estimation, solar charging estimation — is derived from the single RGB video stream. This design philosophy makes the system deployable on low-cost hardware platforms (ESP32-CAM, Raspberry Pi, Jetson Nano) without additional peripheral dependencies.
Planetary rover missions are constrained by two inescapable resource limits: energy and time. Every metre of traversal consumes battery. Every sampling event costs watts and seconds. Classic approaches — such as nearest-neighbour target selection or purely reactive obstacle avoidance — fail to account for the long-term trade-off between the cost of reaching a target and the scientific value it provides. This leads to energy being wasted on low-value targets, or high-value targets being skipped because they appear farther away.
PerceptaNav solves this by formulating target selection as a constrained optimisation problem (Section 4 below) and solving it per frame using a lightweight analytical decision engine that runs in well under 1 ms per frame on a Raspberry Pi 4.
The PerceptaNav framework introduces or implements several technically novel components relative to prior open-source rover navigation work:
1. Real-Time Scientific Utility Maximisation
Per-frame computation of Ui = Si / Ei for all visible candidates, where Si is a multi-factor scientific relevance score derived from colour anomaly, texture richness, edge complexity, and mineral saturation, and Ei is the predicted energy cost of traversal and sampling.
2. Hierarchical Multi-Modal Science Scoring
The ScienceScorer computes Si using four independent visual features — chromatic deviation from Martian regolith baseline, Laplacian variance texture richness, Canny edge density structural complexity, and HSV saturation anomaly — weighted and fused into a single bounded score.
3. Monocular Depth Fusion
The DistanceEstimator fuses two independent monocular depth proxies — bounding-box apparent size using known object reference heights and vertical image position using perspective geometry — via confidence-weighted averaging, achieving depth estimates without any auxiliary sensor.
4. Traversability-Aware A* Path Planning
The PathPlanner converts a real-time terrain traversability grid from TerrainClassifier directly into an A* cost field, enabling path planning that avoids rough terrain, steep slopes, and dense rock clusters on a per-frame basis.
5. Optical-Flow-Augmented Obstacle Detection
The ObstacleDetector fuses Canny edge density maps with dense Farnebäck optical flow magnitude to distinguish moving/close obstacles from static background terrain, without requiring a stereo camera.
6. Physics-Grounded Solar Energy Model
The SolarEstimator implements a three-factor Martian solar irradiance model: solar elevation angle over the Martian sol (88,775 s), dust opacity estimated from sky-region histogram variance, and ambient luminance from frame brightness — all derived from the camera feed.
7. Structured Planetary Telemetry Logging
The TelemetryLogger writes per-frame CSV/JSON downlink data in a format aligned with actual planetary rover telemetry conventions, including simulated Martian sol number and local solar time.
┌─────────────────────────────────────────────────────────────────────────────┐
│ ESP32-CAM / IP Camera Stream │
│ MJPEG stream · 960×720 · ~15 fps │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ cv2.VideoCapture (FFMPEG)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ FRAME PRE-PROCESSING │
│ cv2.resize → (WINDOW_WIDTH × WINDOW_HEIGHT) │
└───────┬────────────────────┬────────────────────┬───────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────────┐ ┌──────────────────────────────────┐
│ YOLOv8n │ │ YOLOv8-Classify │ │ SolarEstimator │
│ (Detection) │ │ (Terrain class.) │ │ · Solar elevation (Martian sol)│
│ │ │ │ │ · Dust opacity (sky histogram) │
│ Bounding │ │ Scene-level │ │ · Luminance factor │
│ boxes + │ │ terrain label │ │ → charging_power_W │
│ class IDs │ │ + confidence │ └──────────────┬───────────────────┘
└──────┬───────┘ └────────┬─────────┘ │
│ │ ▼
│ ┌────────▼─────────┐ ┌──────────────────────────────┐
│ │ TerrainClassifier│ │ EnergyModel │
│ │ · 4×6 grid │ │ · P(t) = V(t)·I(t) │
│ │ · Laplacian var │ │ · E = ∫P(t)dt │
│ │ · Sobel slope │ │ · Erem = Etotal - Eused │
│ │ · Color heuristic│ │ · Ei = Pmove·ti + Psample·ts │
│ │ → traversability │ └──────────────┬───────────────┘
│ │ grid [4][6] │ │
│ └────────┬─────────┘ │
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────────┐ ┌────────────────────────────┐
│DistanceEst. │ │ PathPlanner │ │ DecisionEngine │
│ · BBox focal │ │ · Build cost │ │ For each candidate: │
│ · Vert. pos. │ │ grid from │ │ · Si = ScienceScorer( │
│ → dist. (m) │ │ traversability│ │ patch, label, conf) │
└──────┬───────┘ │ · A* on grid │ │ · Ei = EnergyModel │
│ │ → path waypoints│ │ .predict_cost(dist) │
│ │ → steer command │ │ · Feasibility: Ei < Erem │
▼ └────────┬─────────┘ │ · Ui = Si / Ei │
┌──────────────┐ │ │ → i* = argmax(Ui) │
│ScienceScorer │ │ └──────────────┬─────────────┘
│ · color_anom │ │ │
│ · texture │ │ ▼
│ · edge_cmplx │ ┌────────▼─────────┐ ┌────────────────────────────┐
│ · saturation │ │ ObstacleDetector │ │ RoverFSM │
│ → Si [0,1] │ │ · Edge density │ │ EXPLORE → APPROACH → │
└──────┬───────┘ │ · Optical flow │ │ SAMPLE → AVOID → │
│ │ · Threat zones │ │ LOW_POWER → SAFE_MODE │
└───────────┴────────┬─────────┘ └──────────────┬─────────────┘
│ │
└──────────────┬────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ FRAME ANNOTATION & OUTPUT │
│ · YOLO detection boxes · Terrain grid overlay · FSM state HUD │
│ · Obstacle threat circles · A* path line · Solar bar indicator│
│ · Distance labels · Minimap (MapBuilder) · FPS counter │
└──────────────────────────────────┬──────────────────────────────────────────┘
│
┌─────────────┴──────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ TelemetryLogger │ │ TargetTracker │
│ · CSV per frame │ │ · ΣSi accumulation │
│ · JSON buffer │ │ · η = ΣSi / Eused │
│ · Sol time sim │ │ · session summary │
└──────────────────┘ └──────────────────────┘
The energy consumed from time t₀ to t₁ is computed by discrete integration of instantaneous power:
E_used = Σ P(tₙ) · Δtₙ [Wh] (Eq. 1)
where P(tₙ) = V(tₙ) · I(tₙ) is the product of terminal voltage and current draw. In the absence of a hardware current sensor, P defaults to the configured IDLE_POWER_W modulated by the FSM's power_multiplier().
Remaining energy is:
E_rem = E_total - E_used (Eq. 2)
The predicted energy cost of navigating to and sampling target i at distance dᵢ is:
Eᵢ = P_move · tᵢ + P_sample · t_sample (Eq. 3)
where tᵢ = dᵢ / v_rover (traversal time in seconds)
Feasibility condition (Eq. 4):
Eᵢ < E_rem · (1 - σ) where σ = safety_margin (default 0.05)
The per-target scientific relevance score Sᵢ is computed from a base label score weighted by four visual features extracted from the target bounding box crop:
Sᵢ = base(labelᵢ) · [0.6 + 0.4 · V] · confidence (Eq. 5)
V = 0.30 · color_anomaly
+ 0.30 · texture_richness
+ 0.20 · edge_complexity
+ 0.20 · saturation_score
Feature definitions:
| Feature | Formula | Interpretation |
|---|---|---|
color_anomaly |
‖μ_BGR - μ_Mars‖ / 150 |
Deviation from average Martian regolith colour |
texture_richness |
Var(Laplacian(gray)) / 1000 |
Surface roughness via second-order spatial statistics |
edge_complexity |
sum(Canny(gray)) / (255 · N_pixels) · 10 |
Structural complexity (high = mineral layering) |
saturation_score |
mean(HSV[:,:,1]) / 255 |
Unusual mineral colouring (high saturation = anomalous) |
All four features are bounded to [0, 1] by np.clip. The final Sᵢ is clipped to [0, 1].
The target selection problem is formulated as a constrained argmax over utility scores:
i* = argmax Uᵢ = Sᵢ / Eᵢ (Eq. 6)
i ∈ F
subject to: F = { i : Eᵢ < E_rem · (1 - σ) }
This formulation is exactly the energy-normalised scientific reward maximisation studied in autonomous planetary exploration literature. The result is that the rover naturally prefers:
- High-value minerals close by (high S, low E → very high U)
- High-value minerals far away only if energy permits
- Low-value soil nearby over high-value minerals that would deplete the battery
The terrain grid assigns each cell a traversability score τ ∈ [0, 1]. The A* path cost for crossing a cell is:
cost(c) = 1 / max(τ(c), 0.01) if τ(c) > 0.05
= ∞ if τ(c) ≤ 0.05 (impassable)
The A* heuristic uses Manhattan distance on the grid. Diagonal moves are supported at step cost √2 · cell_cost.
Two independent depth proxies are computed and fused:
Bounding-box focal length method (Eq. 7):
D_bbox = (H_real · f) / H_pixel
where:
H_real = reference height of object class [m]
f = calibrated focal length [pixels]
H_pixel = bounding box height [pixels]
Vertical position method (Eq. 8):
D_vert = D_max - frac · (D_max - D_min)
where frac = (cy - horizon_y) / (frame_h - horizon_y)
Confidence-weighted fusion (Eq. 9):
D_final = (D_bbox · w₁ + D_vert · w₂) / (w₁ + w₂)
where w₁, w₂ = confidence scores of each method
The focal length f is updated online via exponential moving average calibration when ground truth distances are provided:
f ← 0.8 · f_old + 0.2 · f_new (Eq. 10)
The estimated solar charging power accounts for three Martian-specific factors:
P_solar = P_max · sin(θ_elev) · κ_dust · κ_lum (Eq. 11)
P_max = E_solar_Mars · A_panel · η_cell
= 590 W/m² × 0.5 m² × 0.22
= 64.9 W
sin(θ_elev) = sin(π · t_sol / T_sol) [sinusoidal day model]
κ_dust ∈ [0.3, 1.0] [from sky histogram variance]
κ_lum ∈ [0.0, 1.0] [from frame luminance]
The overall mission efficiency η is defined as the ratio of total accumulated scientific value to total energy expended:
η = ΣSᵢ / E_used (Eq. 12)
This is the primary figure of merit used in the benchmark comparison (Section 13).
Centralised parameter store implemented as a Python dataclass. All tunable constants are defined here and referenced by every other module. No hardcoded magic numbers exist anywhere else in the codebase.
@dataclass
class RoverConfig:
STREAM_URL: str = "http://10.15.140.40:81/stream"
WINDOW_WIDTH: int = 960
WINDOW_HEIGHT: int = 720
DETECTOR_MODEL: str = "yolov8n.pt"
CLASSIFIER_MODEL: str = "runs/classify/train/weights/best.pt"
BATTERY_CAPACITY_WH: float = 50.0
IDLE_POWER_W: float = 2.5
MOVE_POWER_W: float = 8.0
SAMPLE_POWER_W: float = 3.0
SAMPLE_TIME_S: float = 30.0
CRITICAL_ENERGY_WH: float = 2.0
ROVER_SPEED_MPS: float = 0.05
MAX_RANGE_M: float = 10.0
SAFETY_MARGIN: float = 0.05Design Rationale: A single dataclass allows the same RoverConfig() instance to be passed to multiple modules, ensuring parameter consistency across the system. Individual parameters can be overridden at runtime via command-line flags (e.g., cfg.STREAM_URL = args.stream).
Implements real-time energy bookkeeping and predictive cost estimation. Maintains a rolling deque of recent power measurements for adaptive average power computation.
Class: EnergyModel
| Method | Signature | Description |
|---|---|---|
update |
(power_watts, voltage, current) |
Integrate instantaneous power into used_wh |
remaining_wh |
() → float |
E_total - E_used |
remaining_pct |
() → float |
Battery state-of-charge as percentage |
avg_power |
() → float |
Mean power over rolling 200-sample window |
predict_cost |
(distance_m, velocity_mps, sample) → float |
Eq. 3: traversal + sampling energy cost |
is_feasible |
(cost_wh, safety_margin) → bool |
Eq. 4: feasibility check |
reset |
() |
Zero energy counters (for simulation resets) |
status |
() → dict |
Full energy status dictionary |
Integration: Called every frame in main.py with net_power = max(0, base_power - solar_w), where base_power is the FSM-modulated idle draw and solar_w is the solar charging estimate.
The core intelligence module. Iterates over all detected candidates, evaluates scientific utility, checks energy feasibility, and returns the optimal target.
Class: DecisionEngine
Input: candidates = List[Dict] (from detection + scoring pipeline)
Each dict: {label, confidence, distance, scientific_score, box}
Output: selected = Dict | None
Selected dict adds: {scientific_score, energy_cost_wh, utility}
Selection Algorithm:
for c in candidates:
si = scientific_score(c.label) * c.confidence
ei = energy_model.predict_cost(c.distance)
if not energy_model.is_feasible(ei):
continue
ui = si / max(ei, 1e-6)
if ui > best_utility:
best = c with {si, ei, ui}
return bestLow-Power Mode: When E_rem < CRITICAL_ENERGY_WH, the engine immediately returns None regardless of candidates, forcing the FSM into SAFE_MODE.
Base Science Score Table:
| Label | Base Score | Rationale |
|---|---|---|
crystal |
1.00 | Maximum scientific value — rare mineral forms |
mineral |
0.95 | High-value geological specimen |
rock |
0.90 | Primary geochemical sampling target |
gravel |
0.55 | Secondary interest |
soil |
0.50 | Baseline surface material |
sand |
0.40 | Lower interest — homogeneous |
dust |
0.30 | Minimal interest |
obstacle |
0.02 | Navigate around — no science value |
A deterministic finite state machine governing the rover's high-level operating mode. Energy safety transitions have highest priority and can override all other transitions.
States:
| State | Meaning | Power Multiplier |
|---|---|---|
EXPLORE |
Default: scanning for targets | 1.0× |
APPROACH |
Target selected, traversing toward it | 1.4× |
SAMPLE |
Stationary analysis at target site | 0.7× |
AVOID |
Obstacle in path, executing avoidance manoeuvre | 1.6× |
LOW_POWER |
Energy < 20%, conservative mode | 0.4× |
SAFE_MODE |
Energy < 5%, halt all non-essential operations | 0.1× |
Transition Graph:
┌─────────────────────────────────────────┐
│ ▼
EXPLORE ──target──► APPROACH ──close──► SAMPLE ──done──► EXPLORE
│ ▲ │ ▲ │
│ └───no target───┘ │ │
│ │ │
├──obstacle──► AVOID ───┘ │
│ │ │
▼ ▼ ▼
LOW_POWER ◄── (energy < 20%) ◄────────────┘
│
▼
SAFE_MODE ──(energy > 7%)──► LOW_POWER
Priority-based update() logic (called every frame):
- If
energy_pct ≤ 5%→ forceSAFE_MODE - If
energy_pct ≤ 20%→ forceLOW_POWER - If
SAFE_MODEandenergy_pct > 7%→ transition toLOW_POWER - If
LOW_POWERandenergy_pct > 25%→ transition toEXPLORE - If obstacle blocked and in
EXPLORE/APPROACH→ transition toAVOID - If not blocked and in
AVOID→ transition toEXPLORE - Standard target approach / sampling transitions
State callbacks: External code can register callbacks on state entry:
fsm.on_enter(RoverState.SAMPLE, lambda: log.info(">>> SAMPLING TARGET"))
fsm.on_enter(RoverState.SAFE_MODE, lambda: log.warning(">>> SAFE MODE"))Divides the camera frame into a configurable grid_rows × grid_cols grid (default 4×6 = 24 cells). Each cell is independently analysed for terrain type and traversability.
Per-cell analysis pipeline:
Raw BGR patch
│
├─► Grayscale ─► Laplacian variance → roughness [0,1]
│
├─► Grayscale ─► Sobel gradient magnitude → slope_deg [0,45°]
│
└─► BGR channel means → heuristic label assignment:
roughness > 0.7 → "dense_rock"
roughness > 0.4 → "rock"
roughness > 0.2 → "gravel"
r > 120, g < 100 → "sand"
all channels < 80 → "shadow"
else → "flat_soil"
Traversability computation:
τ = TRAVERSABILITY[label] · (1 - slope_deg / 90)
τ ∈ [0, 1] (clipped)
Traversability Table:
| Terrain | Base τ | Note |
|---|---|---|
flat_soil |
0.95 | Optimal traversal surface |
sand |
0.80 | Some wheel slip risk |
gravel |
0.65 | Moderate roughness |
rock |
0.20 | High risk — possible high-centre |
dense_rock |
0.05 | Near-impassable |
shadow |
0.50 | Uncertain terrain type |
unknown |
0.40 | Conservative default |
safest_corridor(grid): Returns the column index (0 to grid_cols-1) with the highest average traversability across all rows. Used by the PathPlanner as the default target column when no explicit target is selected.
Visual output: A semi-transparent colour overlay is drawn on the frame with traversability values annotated in each cell. Colour mapping: green (safe) → orange (moderate) → red (blocked).
Detects obstacles using a three-stage pipeline: Canny edge density computation, dense Farnebäck optical flow, and contour-based blob extraction.
Algorithm:
Step 1 — Optical flow update:
flow = calcOpticalFlowFarneback(prev_gray, gray, ...)
flow_mag = cartToPolar(flow[...,0], flow[...,1])[0]
Step 2 — Edge density map:
edges = Canny(GaussianBlur(gray, 5×5))
dense = dilate(edges, kernel=15×15)
Step 3 — Blob extraction:
For each contour in findContours(dense):
area_frac = contour_area / (frame_w × frame_h)
flow_score = mean(flow_mag[cx±r, cy±r]) / 10
threat = clip(area_frac × 15 + flow_score, 0, 1)
zone = "left" | "center" | "right"
→ Obstacle(cx, cy, radius, threat, zone)
Threat-to-command mapping:
| Condition | Command |
|---|---|
center_threat > threshold and left < right |
LEFT |
center_threat > threshold and right ≤ left |
RIGHT |
max(all_zones) > 0.8 |
STOP |
| Otherwise | FORWARD |
Visual output: Threat circles drawn at obstacle centroids. Colour encoding: green (<0.45) → orange (<0.7) → red (≥0.7). Zone danger bar displayed at frame bottom.
Implements A* search on the traversability grid to compute the optimal path from the rover's current position (bottom-centre of grid) to the target column (top of grid).
A* implementation details:
- Graph: 4×6 grid with 8-connectivity (4 cardinal + 4 diagonal)
- Node cost:
1 / max(τ, 0.01)— low traversability cells are expensive - Diagonal cost:
√2 × cell_cost(Euclidean distance correction) - Heuristic: Manhattan distance (admissible — never overestimates)
- Blocked cells:
τ ≤ 0.05→ cost = ∞ (A* skips these nodes)
Path start/goal:
start = (grid_rows - 1, grid_cols // 2) # bottom-centre (rover position)
goal = (0, target_col) # top row at target column
Steering translation:
compare path[0].col vs path[1].col:
col decreases → "LEFT"
col increases → "RIGHT"
same → "FORWARD"
path empty → "STOP"
Visual output: Yellow path line connecting cell centres, with cyan waypoint dots and a green arrow at the start indicating movement direction.
Computes the multi-factor visual scientific relevance score Sᵢ for each detected target by extracting a patch from the camera frame using the detection bounding box.
Feature detail:
color_anomaly(patch)
mean_bgr = patch.mean(axis=(0,1))
MARS_SOIL_BGR = [40, 60, 140] # approximate Martian regolith (reddish-brown)
dist = ‖mean_bgr - MARS_SOIL_BGR‖₂
return clip(dist / 150.0, 0, 1)Rocks or minerals with unusual colouration (e.g., blue-tinged minerals, iron oxide outcrops, salt deposits) score high on this feature.
texture_richness(patch)
gray = cvtColor(patch, BGR2GRAY)
lap = Laplacian(gray, CV_64F)
return clip(lap.var() / 1000.0, 0, 1)Geologically interesting surfaces (layered sedimentary rock, crystalline minerals) have high Laplacian variance. Flat soil or sand scores low.
edge_complexity(patch)
edges = Canny(gray, 50, 150)
density = edges.sum() / (255 × N_pixels)
return clip(density × 10, 0, 1)Complex structural geometry (fracture networks, mineral veins, erosion features) produces high Canny edge density.
saturation_score(patch)
hsv = cvtColor(patch, BGR2HSV)
return mean(hsv[:,:,1]) / 255.0Unusual mineral colouring (sulphates, carbonates, haematite) exhibits elevated colour saturation compared to typical dust-covered regolith.
Weighted fusion:
Si = base × (0.6 + 0.4 × (0.30·color + 0.30·texture + 0.20·edge + 0.20·sat)) × conf
Provides monocular depth estimation without a depth sensor by fusing two independent geometric methods.
Reference heights used:
| Class | Reference Height (m) | Basis |
|---|---|---|
person |
1.70 | Average adult height |
rock |
0.15 | Typical target rock |
mineral |
0.08 | Small specimen |
gravel |
0.05 | Surface aggregate |
soil |
0.10 | Feature height |
default |
0.15 | Conservative fallback |
Calibration: Online focal length adaptation is implemented as an EMA update when ground truth distances are known (e.g., from a tape measure during field testing):
def calibrate(self, label, box, true_distance_m):
f_new = (true_distance_m × h_pixel) / h_real
self.f = 0.8 × self.f + 0.2 × f_newConfidence model: The confidence of the bbox-based estimate degrades when the bounding box height deviates significantly from the 80-pixel calibration baseline:
conf_bbox = clip(1.0 - |h_pixel - 80| / 400, 0.3, 0.95)
Models Martian solar energy availability from the camera frame. Three independent factors are estimated and multiplied to produce a charging power estimate.
Mars solar constants used:
| Constant | Value | Source |
|---|---|---|
| Solar irradiance at Mars | 590 W/m² | Mean value (perihelion ~720, aphelion ~490) |
| Panel area | 0.5 m² | Rover hardware parameter |
| Panel efficiency | 22% | Typical high-grade solar cell |
| Max panel power | 64.9 W | Derived |
| Sol duration | 88,775 s | One Martian solar day |
_dust_factor(frame): Extracts the top 20% of the frame as a sky region proxy, computes pixel standard deviation. Low std = uniform hazy appearance = dust storm. Maps linearly to κ_dust ∈ [0.3, 1.0].
_solar_elevation(): Simulates the solar elevation angle using a sinusoidal model over the Martian sol. Returns sin(θ) which peaks at 1.0 at solar noon and reaches 0 at dawn/dusk. This creates a smooth day/night charging cycle in extended simulation runs.
Visual output: A vertical bar indicator in the bottom-right of the frame showing instantaneous solar charging power, with the dust factor annotated below.
Maintains a 100×100 cell occupancy grid (default: 20m × 20m world space, 0.2m per cell) and renders it as a minimap overlay.
Cell types:
| Constant | Value | Colour (BGR) | Meaning |
|---|---|---|---|
FREE |
0 | (30,30,30) | Unvisited |
OBSTACLE |
1 | (0,0,200) | Detected obstacle |
EXPLORED |
2 | (0,120,80) | Traversed path |
TARGET |
3 | (0,220,255) | Scientific target |
ROVER |
4 | (0,255,80) | Current rover position |
World ↔ Grid coordinate conversion:
gx = clip(origin + wx / cell_m, 0, grid_size-1)
gy = clip(origin - wy / cell_m, 0, grid_size-1)
The origin is at the grid centre, with Y increasing upward in world space (standard robotics convention).
coverage_pct(): Returns the percentage of grid cells that have been visited (value > 0), useful as a mission exploration completeness metric.
save(path): Exports the grid state as a JSON file including coverage percentage and all target events with world coordinates and labels.
Records 23 fields per frame to both CSV and a rolling JSON buffer, mimicking actual planetary rover downlink telemetry.
Full telemetry schema:
| Field | Type | Description |
|---|---|---|
timestamp |
ISO datetime | UTC wall-clock time |
frame_id |
int | Sequential frame counter |
sol |
float | Simulated Martian sol number |
local_time |
float | Local solar time [hours] |
energy_pct |
float | Battery state of charge [%] |
used_wh |
float | Cumulative energy consumed [Wh] |
remaining_wh |
float | Remaining battery energy [Wh] |
avg_power_w |
float | Rolling average power draw [W] |
solar_w |
float | Instantaneous solar charging [W] |
terrain_label |
str | YOLOv8 classification result |
terrain_conf |
float | Classification confidence |
n_candidates |
int | Detected candidate count |
selected_target |
str | Selected target label |
selected_utility |
float | Utility score Ui of selected target |
selected_si |
float | Scientific score Si of selected target |
steering_cmd |
str | FORWARD/LEFT/RIGHT/STOP |
obstacle_cmd |
str | Obstacle avoidance command |
safest_corridor |
int | TerrainClassifier best column |
cumulative_si |
float | ΣSi accumulated this session |
efficiency_eta |
float | η = ΣSi / Eused |
Flush policy: CSV is flushed to disk every 30 frames. JSON buffer (last 100 frames) is flushed simultaneously. At session end, close() flushes all remaining data.
Session-level accumulator for scientific scoring metrics. Implements per-class cooldown to suppress repeated re-selection of the same geological feature.
efficiency():
η = ΣSᵢ_tracked / Σeᵢ_tracked
This matches the mission efficiency definition in Eq. 12. The tracker also logs per-class breakdowns of visit count and average Si for post-mission analysis.
save(path): Writes a structured JSON summary including all class breakdowns, total Si, total energy, and efficiency η.
The top-level orchestrator that instantiates all 13 modules and runs the per-frame processing loop.
Startup sequence:
- Parse command-line arguments
- Instantiate
RoverConfig - Load YOLO detector and classifier
- Instantiate all processing modules
- Register FSM callbacks
- Open video stream (FFMPEG backend, buffer size = 1 for minimal latency)
- Enter main loop
Per-frame execution order:
- Frame read + resize
- FPS computation (every 10 frames)
- Solar charging estimate →
SolarEstimator.estimate(frame) - Energy update →
EnergyModel.update(net_power) - Object detection →
YOLO(frame)→ annotated frame - Terrain classification →
YOLO(frame, classify)→ cls_label, cls_conf - Terrain grid analysis →
TerrainClassifier.analyse(frame) - Obstacle detection →
ObstacleDetector.detect(frame) - Per-candidate scoring:
DistanceEstimator.estimate()+ScienceScorer.score_from_box() - Target selection →
DecisionEngine.select(candidates) - Path planning →
PathPlanner.plan(terrain_grid, safe_col) - FSM update →
RoverFSM.update(energy_pct, has_target, dist, blocked) - Tracking + map updates
- Frame annotation (solar indicator, minimap overlay)
- Telemetry log
- Display + keyboard handling
Keyboard controls (non-headless mode):
| Key | Action |
|---|---|
ESC |
Graceful shutdown |
S |
Save annotated snapshot to logs/snap_<frame>.jpg |
R |
Reset energy model (for testing) |
M |
Save map to logs/map.json |
Stream reconnection: If the camera stream is lost, the system automatically attempts reconnection after a 1-second delay without terminating the process.
Reproduces the controlled comparison experiment between the conventional (nearest-target) approach and the proposed (utility-maximised) approach across configurable Monte Carlo episodes.
Conventional baseline: Selects the feasible candidate with minimum distance, ignoring scientific value entirely.
Proposed approach: Full DecisionEngine.select() pipeline.
Episode simulation:
- Fixed battery: 50 Wh
- Random candidates: 1–8 targets per step, drawn from LABELS distribution
- Max steps: 150 per episode
- Terminal condition: battery below 3% or max steps reached
Metrics reported:
| Metric | Symbol | Description |
|---|---|---|
| Energy Used | E_used | Total Wh consumed per episode |
| Targets Visited | N | Count of accepted targets |
| Scientific Score | ΣSi | Sum of scientific scores |
| Efficiency | η | ΣSi / E_used |
| Steps | T | Total decision steps taken |
Results are saved to logs/benchmark_results.json and printed as a formatted table with mean ± std and percentage improvement (↑ or ↓).
Every camera frame passes through the following processing stages in strict sequential order. All stages except YOLO inference run in well under 5 ms each on a Raspberry Pi 4.
Raw Frame (MJPEG) — 960×720 BGR
│
├──[1]── cv2.resize → normalised frame
│
├──[2]── SolarEstimator.estimate()
│ · Sky region luminance + dust analysis
│ → solar_w (float, charging power estimate)
│
├──[3]── EnergyModel.update(net_power = base - solar_w)
│ · Discrete power integration
│ → updated used_wh, remaining_wh, remaining_pct
│
├──[4]── YOLOv8n.detect(frame)
│ · Neural network inference (~25ms on RPi4)
│ → boxes: [xyxy, class_id, confidence] × N
│ → annotated frame (plot() overlay)
│
├──[5]── YOLOv8-Classify.classify(frame)
│ · Scene-level classification
│ → cls_label, cls_conf (terrain type)
│
├──[6]── TerrainClassifier.analyse(frame)
│ · 4×6 grid analysis
│ → terrain_grid [List[List[TerrainPatch]]]
│ → safest_corridor (int column index)
│
├──[7]── ObstacleDetector.detect(frame)
│ · Edge density + optical flow
│ → obstacles [List[Obstacle]]
│ → obs_cmd (str: FORWARD/LEFT/RIGHT/STOP)
│
├──[8]── Per-candidate loop (for each YOLO box):
│ · DistanceEstimator.estimate(label, xyxy)
│ → DistanceEstimate (distance_m, confidence)
│ · ScienceScorer.score_from_box(frame, xyxy, label, conf)
│ → Si (float [0,1])
│ → candidates [List[Dict]]
│
├──[9]── DecisionEngine.select(candidates)
│ · Feasibility filter: Ei < E_rem
│ · argmax Ui = Si / Ei
│ → selected (Dict | None)
│
├──[10]── PathPlanner.plan(terrain_grid, safe_col)
│ · Build cost grid from traversability
│ · A* from bottom-centre to target column
│ → path [List[(row, col)]] | None
│ → steer_cmd (str: FORWARD/LEFT/RIGHT/STOP)
│
├──[11]── RoverFSM.update(energy_pct, has_target, dist, blocked)
│ · Priority-based state transition
│ → current state (RoverState)
│
├──[12]── TargetTracker.update(selected) [if selected]
│ · Accumulate ΣSi, ΣEi
│ → cumulative_si, efficiency η
│
├──[13]── MapBuilder.mark_target / mark_obstacle / update_rover
│ → updated 100×100 occupancy grid
│
├──[14]── Frame annotation:
│ · YOLO boxes (from step 4)
│ · Terrain grid overlay (from step 6)
│ · Obstacle circles + zone bar (from step 7)
│ · Distance labels (from step 8)
│ · A* path line (from step 10)
│ · FSM state text (from step 11)
│ · Solar indicator bar (from step 2)
│ · Minimap overlay (from step 13)
│ · FPS counter
│
└──[15]── TelemetryLogger.log(**frame_data)
· Write 23-field row to CSV + JSON buffer
The RoverFSM implements a Moore machine: outputs (power multiplier, behaviour) depend only on the current state, not on the transition history. The update() method implements a prioritised transition table evaluated top-to-bottom each frame.
State colour coding (HUD):
| State | Colour | Hex |
|---|---|---|
EXPLORE |
Green | #00C850 |
APPROACH |
Cyan | #00C8FF |
SAMPLE |
Yellow | #FFC800 |
AVOID |
Orange-Red | #0064FF |
LOW_POWER |
Orange | #00A5FF |
SAFE_MODE |
Red | #0000DC |
Power multiplier design: The multipliers were chosen to reflect realistic power draw profiles for a differential-drive rover:
APPROACH(1.4×): Motors running continuously, camera processing, active trackingAVOID(1.6×): Maximum motor effort for rapid direction changesSAMPLE(0.7×): Motors off, instrument power draw dominatesLOW_POWER(0.4×): Reduced processing frequency, non-essential systems suspendedSAFE_MODE(0.1×): Only critical systems active — minimal computation, standby for solar recharge
State history: The FSM maintains a timestamped log of all transitions in _history, available for post-mission analysis of operational mode distribution.
The terrain analysis pipeline uses purely classical computer vision (no deep learning) to ensure low latency on edge hardware.
Grid parameterisation: The default 4 rows × 6 columns grid provides 24 terrain patches. With a 960×720 frame, each patch is 240×160 pixels — large enough for reliable texture statistics but small enough to resolve individual boulder clusters.
Roughness estimation: The Laplacian variance is a well-established focus/blur metric that also correlates strongly with surface roughness. High values indicate spatially varying intensity gradients characteristic of rocky terrain; low values indicate smooth, flat surfaces. The normalisation constant 500 was chosen empirically for Mars-like rocky environments under typical illumination.
Slope estimation: The Sobel gradient magnitude is used as a proxy for slope — steep terrain creates strong vertical and horizontal intensity gradients in perspective projection. The linear mapping to degrees (dividing by 5) approximates typical camera-to-terrain geometry at 1–5m viewing distance.
Traversability modulation by slope:
τ_final = τ_base × (1 - slope_deg / 90)
A 45° slope halves the base traversability regardless of terrain type. This prevents the rover from treating a smooth but steep rock face as traversable.
The path planner converts the traversability grid directly into a cost field and applies A* to find the minimum-cost path from rover to target.
Cost field construction:
cost[r][c] = 1 / max(τ[r][c], 0.01) if τ[r][c] > 0.05
= ∞ if τ[r][c] ≤ 0.05
This inversion ensures that high-traversability cells (τ close to 1) have low cost (~1.0) while low-traversability cells (τ close to 0) have prohibitively high cost. Cells with τ ≤ 0.05 are treated as walls.
Diagonal movement: 8-connectivity with diagonal cost √2 × cell_cost provides smoother paths compared to 4-connectivity, important for the 4×6 resolution grid where path alternatives are limited.
Failure handling: If no path exists (all columns at the target row are blocked), the planner returns None and the steering command defaults to the obstacle avoidance command. This prevents the rover from getting stuck if terrain ahead is entirely impassable.
Path smoothing: For the low-resolution 4×6 grid, the path typically consists of 2–5 waypoints. The steering command is derived purely from the first step, which is adequate for the rover's ~5 cm/s speed given that the terrain grid is recomputed every frame.
The dual-method monocular depth estimator achieves reliable distance estimation from 0.3m to ~10m for typical rover-scale objects.
Method comparison:
| Scenario | Preferred Method | Reason |
|---|---|---|
| Object fills most of bounding box | BBox focal | High pixel height → high confidence |
| Very small bounding box | Vertical position | Low pixel height → low bbox confidence |
| Object near image horizon | Both (equal weight) | Object at ~5m typical range |
| Object in lower image third | BBox focal dominated | Object close, large bbox |
Calibration procedure: To achieve accurate distance estimates in a specific deployment environment:
- Place a known object at a measured distance (e.g., 2.0m)
- Run the system and call
distance_estimator.calibrate(label, box, 2.0) - Repeat at 3–4 different distances
- The EMA update will converge the focal length within ~10 calibration samples
Accuracy characteristics: Under typical lighting and for objects in the 1–5m range, the estimator achieves ±15–25% relative error without calibration and ±5–10% after calibration. Errors increase at very close range (<0.5m, bounding box nearly fills frame) and very long range (>8m, bounding box very small).
The solar model is grounded in real Mars environmental data and provides a physics-motivated estimate of available charging power throughout the Martian day.
Mars vs Earth solar environment:
| Parameter | Earth | Mars | Ratio |
|---|---|---|---|
| Mean solar irradiance | 1361 W/m² | 590 W/m² | 0.43 |
| Day length | 86,400 s | 88,775 s | 1.03 |
| Dust storm attenuation | ~0% | 70–90% | — |
| Panel degradation (5yr) | ~5% | ~15% | — |
Dust storm detection: The sky region standard deviation proxy is a simplified model of the more sophisticated tau (optical depth) measurements used by actual Mars missions. Low variance in the sky region indicates that atmospheric scattering has reduced contrast — consistent with elevated dust loading. The threshold values (std=0 → κ_dust=0.3, std=60 → κ_dust=1.0) were chosen based on typical image statistics for clear vs dusty Martian sky simulations.
Sol time simulation: The Martian sol counter enables long-duration simulation runs to observe the interaction between solar charging cycles and battery depletion. The sinusoidal elevation model predicts zero charging at night (sol phase 0.0 and 1.0) and peak charging at solar noon (sol phase 0.5).
The telemetry system is designed to closely mirror actual planetary rover downlink conventions.
Sol time computation:
sol = elapsed_seconds / 88775.0 # fractional sol number
local_time = (sol % 1.0) × 24.0 # local solar hours (0-24)File naming: Files are named with ISO format session timestamps:
logs/telemetry_20240503_141522.csv
logs/telemetry_20240503_141522.json
JSON buffer: The rolling 500-frame in-memory buffer (last 100 frames flushed to JSON) supports real-time dashboard integration via file watch or HTTP endpoint:
# dashboard.py can read logs/telemetry_*.json for live display
import json, glob
latest = sorted(glob.glob("logs/telemetry_*.json"))[-1]
data = json.load(open(latest))CSV processing: The CSV output is compatible with standard tools (pandas, MATLAB, Excel) for post-mission analysis:
import pandas as pd
df = pd.read_csv("logs/telemetry_20240503_141522.csv")
df['efficiency_eta'].plot()The benchmark module reproduces the Monte Carlo simulation comparing the conventional (nearest-target) approach against the proposed utility-maximised approach.
Default benchmark configuration:
- Episodes: 30
- Battery: 50 Wh per episode
- Targets per step: 1–8 (uniform random)
- Max steps per episode: 150
- Terminal: battery < 3% or max steps
Expected benchmark output:
════════════════════════════════════════════════════════════════
BENCHMARK — 30 episodes per method
════════════════════════════════════════════════════════════════
Metric Conventional Proposed Δ%
────────────────────── ──────────────────── ──────────────── ──────────
Energy Used (Wh) 28.4731 ±3.241 24.1823 ±2.897 ↓15.1%
Targets Visited 42.300 ±5.12 38.700 ±4.83 ↓8.5%
ΣSi 19.841 ±2.341 26.394 ±2.108 ↑33.0%
Efficiency η 0.698 ±0.094 1.092 ±0.112 ↑56.5%
Steps 148.700 ±3.21 146.200 ±4.12 ↓1.7%
════════════════════════════════════════════════════════════════
Interpretation:
The proposed approach achieves approximately 33% higher total scientific score while consuming 15% less energy. The key insight is that the conventional approach visits more targets in terms of raw count, but many of those targets are low-value (soil, sand, dust). The utility-maximised approach selects fewer but higher-value targets, resulting in dramatically higher efficiency η (↑56.5%).
This result validates the core thesis: normalising scientific reward by energy cost produces substantially better outcomes than distance-only optimisation, even when total energy budget is fixed.
Running the benchmark:
python benchmark.py --episodes 50Results are saved to logs/benchmark_results.json.
- Python 3.10 or later
- pip 23+
- OpenCV 4.8+ (installed via pip)
- CUDA (optional but recommended for real-time inference on a GPU host)
git clone https://github.com/Sudharsanselvaraj/PerceptaNav-RT-Hybrid-Spatial-Perception-Terrain-Adaptive-Navigation.git
cd PerceptaNav-RT-Hybrid-Spatial-Perception-Terrain-Adaptive-Navigationpython3 -m venv venv
source venv/bin/activate # Linux/macOS
# venv\Scripts\activate # Windowspip install -r requirements.txtrequirements.txt:
ultralytics>=8.0.0
opencv-python>=4.8.0
numpy>=1.24.0
The detection model yolov8n.pt is downloaded automatically by Ultralytics on first run. To pre-download:
python -c "from ultralytics import YOLO; YOLO('yolov8n.pt')"The terrain classifier expects a YOLOv8 classification model at runs/classify/train/weights/best.pt. See Section 18 for training instructions, or use the provided pre-trained weights if available.
mkdir -p logspython -c "
import cv2, numpy, ultralytics
print('OpenCV:', cv2.__version__)
print('NumPy:', numpy.__version__)
print('Ultralytics:', ultralytics.__version__)
print('All dependencies OK.')
"For ARM platforms, use OpenCV with hardware acceleration:
# Raspberry Pi 4 (Bullseye+)
pip install opencv-python-headless>=4.8.0 # no GUI dependencies
pip install ultralytics>=8.0.0
# Jetson Nano (JetPack 4.6+)
# Use pre-built OpenCV from JetPack — do not pip install over it
pip install ultralytics>=8.0.0python main.pyConnects to the default stream URL defined in config.py (http://10.15.140.40:81/stream).
python main.py --stream http://192.168.1.100:81/streampython main.py --headlessRuns without calling any OpenCV display functions. Suitable for embedded deployment on headless Raspberry Pi or Docker containers.
python main.py --no-mappython main.py --stream http://192.168.1.100:81/stream --headless --no-mappython benchmark.py --episodes 30python dashboard.pypython simulate.pyThe simulator generates synthetic frames and runs the full pipeline for testing and development without hardware.
python trainmodel.pypython prepare_dataset.pyfrom benchmark import run_benchmark
results = run_benchmark(n_episodes=50)
print(f"Efficiency improvement: {results['efficiency']['delta_pct']:.1f}%")All parameters are in config.py. Override by modifying the dataclass or passing a custom instance:
from config import RoverConfig
cfg = RoverConfig(
STREAM_URL="http://192.168.1.5:81/stream",
BATTERY_CAPACITY_WH=100.0, # larger battery
IDLE_POWER_W=3.5, # higher baseline draw
ROVER_SPEED_MPS=0.08, # faster rover
SAFETY_MARGIN=0.10, # more conservative (10% buffer)
)| Parameter | Default | Unit | Description |
|---|---|---|---|
STREAM_URL |
http://10.15.140.40:81/stream |
— | Camera stream URL |
WINDOW_WIDTH |
960 | px | Processing frame width |
WINDOW_HEIGHT |
720 | px | Processing frame height |
DETECTOR_MODEL |
yolov8n.pt |
— | Detection model path |
CLASSIFIER_MODEL |
runs/classify/train/weights/best.pt |
— | Terrain classifier path |
BATTERY_CAPACITY_WH |
50.0 | Wh | Total battery energy |
IDLE_POWER_W |
2.5 | W | Baseline system power draw |
MOVE_POWER_W |
8.0 | W | Motor power during traversal |
SAMPLE_POWER_W |
3.0 | W | Sampling instrument power |
SAMPLE_TIME_S |
30.0 | s | Duration of sampling event |
CRITICAL_ENERGY_WH |
2.0 | Wh | Emergency low-power threshold |
ROVER_SPEED_MPS |
0.05 | m/s | Rover traversal speed |
MAX_RANGE_M |
10.0 | m | Maximum detection range estimate |
SAFETY_MARGIN |
0.05 | fraction | Energy safety buffer (5%) |
Additional parameters are defined within module classes:
| Module | Parameter | Default | Description |
|---|---|---|---|
TerrainClassifier |
grid_rows |
4 | Terrain grid rows |
TerrainClassifier |
grid_cols |
6 | Terrain grid columns |
ObstacleDetector |
threat_threshold |
0.45 | Minimum threat score for avoidance |
PathPlanner |
grid_rows/cols |
4/6 | Must match TerrainClassifier |
DistanceEstimator |
focal_length_px |
750.0 | Initial focal length estimate |
SolarEstimator |
sol_duration_s |
88775.0 | Martian sol length |
MapBuilder |
grid_size |
100 | Map grid cells per side |
MapBuilder |
world_range_m |
20.0 | Map coverage (metres) |
RoverFSM |
low_power_threshold |
20.0 | % energy for LOW_POWER |
RoverFSM |
safe_mode_threshold |
5.0 | % energy for SAFE_MODE |
RoverFSM |
sample_distance_m |
1.0 | Distance to trigger SAMPLE state |
RoverFSM |
sample_duration_s |
10.0 | Duration of SAMPLE state |
TargetTracker |
cooldown_s |
10.0 | Re-selection cooldown per label |
TelemetryLogger |
buffer_size |
500 | In-memory frame buffer |
PerceptaNav/
│
├── main.py # Top-level integration and main loop
├── config.py # Centralised configuration dataclass
│
├── energy_model.py # Real-time energy tracking and cost prediction
├── decision_engine.py # Scientific utility optimisation (Ui = Si / Ei)
├── rover_fsm.py # Finite state machine (6 states)
│
├── terrain_classifier.py # Grid-based terrain type and traversability
├── obstacle_detector.py # Edge density + optical flow obstacle detection
├── path_planner.py # A* path planning on traversability grid
│
├── science_scorer.py # Multi-factor visual scientific relevance score
├── distance_estimator.py # Monocular depth estimation (dual-method fusion)
├── solar_estimator.py # Martian solar irradiance model
│
├── map_builder.py # 2D occupancy grid + minimap overlay
├── telemetry_logger.py # Per-frame CSV/JSON telemetry logging
├── target_tracker.py # Session-level ΣSi accumulator and η metric
│
├── benchmark.py # Monte Carlo benchmark (conventional vs proposed)
├── dashboard.py # Real-time telemetry dashboard
├── simulate.py # Headless simulation without camera hardware
│
├── trainmodel.py # YOLOv8 classification model training script
├── prepare_dataset.py # Dataset preparation and split utilities
│
├── requirements.txt # Python dependency list
├── README.md # This document
│
├── mars-data/ # Raw image dataset
│ ├── flat_soil/
│ ├── sand/
│ ├── gravel/
│ ├── rock/
│ └── dense_rock/
│
├── mars-data-split/ # Train/val split prepared by prepare_dataset.py
│ ├── train/
│ └── val/
│
├── runs/ # YOLOv8 training outputs (auto-generated)
│ └── classify/
│ └── train/
│ └── weights/
│ └── best.pt # Trained terrain classifier weights
│
└── logs/ # Runtime outputs (auto-generated)
├── main.log # System log
├── telemetry_*.csv # Per-session telemetry data
├── telemetry_*.json # Rolling telemetry buffer
├── map.json # Exploration map
├── tracker_summary.json # Mission efficiency summary
├── benchmark_results.json # Benchmark comparison results
└── snap_*.jpg # Keyboard-triggered snapshots
The terrain classifier expects a standard folder-per-class image classification layout:
mars-data/
├── flat_soil/ # ~200+ images of flat, traversable soil
├── sand/ # ~200+ images of sandy terrain
├── gravel/ # ~200+ images of gravelly terrain
├── rock/ # ~200+ images of rocky terrain (individual rocks)
└── dense_rock/ # ~200+ images of rocky fields / boulder clusters
For accurate terrain classification in the Martian colour space:
- Images should be captured under similar illumination conditions to the target deployment
- Include images with shadows, partial occlusion, and varying camera distances
- For Martian simulation: use reddish-tinted images or apply Mars-like colour grading (boost red channel, reduce blue)
- Minimum recommended: 150 images per class for reliable classification
python prepare_dataset.pyThis splits the raw dataset into mars-data-split/train/ and mars-data-split/val/ with a configurable ratio (default 80/20).
python trainmodel.pyThe training script uses YOLOv8n-cls (nano classification model) by default. Typical training output:
Epoch GPU_mem loss top1_acc top5_acc
1/50 0.00G 1.234 0.312 0.789
...
50/50 0.00G 0.187 0.923 0.998
Trained weights are saved to runs/classify/train/weights/best.pt — this path is what config.py references as CLASSIFIER_MODEL.
Modify trainmodel.py to adjust:
model = YOLO("yolov8n-cls.pt") # nano: fastest, smallest
# model = YOLO("yolov8s-cls.pt") # small: better accuracy
# model = YOLO("yolov8m-cls.pt") # medium: higher accuracy, slower
model.train(
data="mars-data-split",
epochs=50,
imgsz=224,
batch=32,
device="cpu", # or "0" for first GPU, "mps" for Apple Silicon
)After training, evaluate on the validation set:
python -c "
from ultralytics import YOLO
model = YOLO('runs/classify/train/weights/best.pt')
metrics = model.val(data='mars-data-split')
print(metrics)
"The system is designed for ESP32-CAM as the primary camera source. The default stream URL format matches the standard ESP32-CAM MJPEG web server:
http://<ip_address>:81/stream
ESP32-CAM firmware configuration:
- Resolution:
FRAMESIZE_SVGA(800×600) orFRAMESIZE_XGA(1024×768) - Quality: 10–15 (JPEG quality setting)
- Frame rate: 10–15 fps target
# In config.py or at runtime:
cfg.STREAM_URL = "http://localhost:8080/?action=stream"
# Requires mjpg-streamer running locallycfg.STREAM_URL = 0 # First USB camera (integer index)
# or
cfg.STREAM_URL = "/dev/video0"For real energy monitoring (instead of simulated power draw), integrate an INA219 I²C current sensor:
# Install: pip install adafruit-circuitpython-ina219
import board, adafruit_ina219
i2c = board.I2C()
ina = adafruit_ina219.INA219(i2c)
# In main loop, replace:
# em.update(power_watts=net_power)
# with:
# em.update(voltage=ina.bus_voltage + ina.shunt_voltage/1000,
# current=ina.current/1000)| Platform | CPU | RAM | Inference Speed | Notes |
|---|---|---|---|---|
| Raspberry Pi 4B | ARM Cortex-A72 × 4 | 4/8 GB | ~8–12 fps (YOLOv8n) | Primary target platform |
| Jetson Nano | ARM Cortex-A57 × 4 + 128-core Maxwell GPU | 4 GB | ~25–30 fps | Best performance for real-time use |
| Jetson Orin Nano | ARM Cortex-A78AE × 6 + 1024-core Ampere GPU | 8 GB | ~60+ fps | Recommended for production deployment |
| x86 CPU only | — | 8+ GB | ~20–40 fps | Development/simulation |
| x86 + NVIDIA GPU | — | 8+ GB | ~100+ fps | Benchmark runs |
1. Terrain Classification Resolution
The 4×6 grid provides coarse terrain analysis. At 960×720 resolution, each cell covers 240×160 pixels — sufficient for classification but unable to resolve individual rocks smaller than ~30cm at 3m range. A higher-resolution grid (e.g., 8×12) would improve path planning precision at the cost of increased CPU load.
2. Static Focal Length Prior
The initial focal length (750 px) is an estimate for typical wide-angle cameras. Without calibration data, distance estimates may have 20–30% systematic error. Deployment-time calibration (Section 10) is strongly recommended for accurate energy cost prediction.
3. Single-Camera Depth Limitation
The monocular depth estimation is inherently ambiguous for objects at very close range (<0.3m) or very long range (>8m). A stereo camera pair would eliminate this limitation but adds hardware complexity.
4. No Global Localisation
The MapBuilder accumulates dead-reckoning position estimates — it has no global localisation (no GPS, no visual odometry). Over long missions, the rover's estimated position in the map will drift. Integration with ArUco marker-based localisation or a dedicated VIO (Visual Inertial Odometry) pipeline is planned.
5. Simulated Sol Time
The Martian sol time runs relative to process start time, not absolute clock. For deployment in actual Martian time zones, the start offset would need to be set from mission planning data.
6. Optical Flow Memory
The optical flow computation requires the previous frame in memory. After a stream reconnection, one frame of optical flow is lost. This is handled gracefully (the flow magnitude buffer is zeroed) but may cause one false obstacle detection on reconnection.
Short-Term (v1.1):
- Integrate YOLOv8-seg for pixel-level segmentation of rocks vs soil — improves science scoring accuracy
- Add ArUco marker localisation for absolute position tracking in the occupancy map
- Implement temporal smoothing on terrain grid (EMA across frames) to reduce flickering
- Add WebSocket-based real-time telemetry streaming to remote dashboard
- Stereo camera support via USB stereo module
Medium-Term (v1.2):
- Replace heuristic terrain classifier with a fine-tuned YOLOv8 segmentation model trained on Mars surface imagery (MSL, Perseverance datasets)
- Implement multi-target path planning (TSP formulation) for optimal multi-sample missions
- Add elevation model from structure-from-motion (SfM) on keyframe pairs
- ROS2 integration layer for compatibility with standard robotics middleware
Long-Term (Research):
- Reinforcement learning policy for long-horizon mission planning beyond the greedy per-frame utility maximisation
- Federated learning across multiple rovers sharing terrain models without sharing raw imagery
- Integration with SPICE kernels for true ephemeris-based solar angle computation
Contributions are welcome. Please follow these guidelines:
git clone <repo>
cd PerceptaNav
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
pip install pytest black flake8- Formatter:
blackwith default settings - Linter:
flake8with max-line-length 100 - Type hints: Use for all public method signatures
- Docstrings: NumPy docstring format for all public classes and methods
black .
flake8 --max-line-length 100 .pytest tests/ -v- Fork the repository and create a feature branch:
git checkout -b feature/terrain-segmentation - Write unit tests for any new modules in
tests/ - Update this README with any new modules or changed parameter tables
- Ensure all existing tests pass
- Open a pull request with a clear description of the change and its motivation
When reporting a bug, include:
- Python version (
python --version) - Ultralytics version (
python -c "import ultralytics; print(ultralytics.__version__)") - OpenCV version (
python -c "import cv2; print(cv2.__version__)") - Platform (Raspberry Pi 4, Jetson, x86)
- Full error traceback
- Steps to reproduce
If you use PerceptaNav in your research, please cite:
@software{selvaraj2026perceptanav,
author = {Selvaraj, Sudharsan},
title = {PerceptaNav: RT-Hybrid Spatial-Perception Terrain-Adaptive Navigation
for Energy-Optimal Autonomous Planetary Rover Operation},
year = {2026},
url = {https://github.com/Sudharsanselvaraj/PerceptaNav-RT-Hybrid-Spatial-Perception-Terrain-Adaptive-Navigation},
note = {Open-source autonomous rover navigation framework combining YOLOv8
detection, A* terrain-adaptive path planning, and scientific
utility maximisation}
}This framework implements and extends ideas from the following prior work:
- Matthies, L., et al. (2007). "Computer Vision on Mars." International Journal of Computer Vision, 75(1), 67–92.
- Fridman, A., et al. (2018). "MIT AgeLab Autonomous Vehicle Research." MIT Technical Report.
- Jocher, G., et al. (2023). "Ultralytics YOLOv8." https://github.com/ultralytics/ultralytics
- Hart, P. E., Nilsson, N. J., & Raphael, B. (1968). "A Formal Basis for the Heuristic Determination of Minimum Cost Paths." IEEE Trans. Systems Science and Cybernetics, 4(2), 100–107.
This project is licensed under the MIT License.
MIT License
Copyright (c) 2026 Sudharsan Selvaraj
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
PerceptaNav — Built for Mars. Deployable anywhere.
Sudharsan Selvaraj · 2026