/ projects

blog record — Feb 2026

Auto Park

Autonomous parking stack for UWAFT's competition EV. Perception → planning → control, validated in CARLA.

stack
ROS2CARLAYOLOHybrid A*PID
01motivation

Parking is harder than driving

Highway driving is almost trivial in a planning sense: stay in the lane, never go backwards, useful trajectories live in a narrow envelope. Parking violates all of that.

A car is non-holonomic. Velocity has to point along the heading; you can't strafe. Configuration space is 3D (x, y, θ) but control space is 2D (steering, throttle). Reachable states form a thin curved manifold in SE(2), not a ball. In a tight bay the goal pose lives somewhere no forward-only trajectory can touch. You have to weave and reverse.

Clearances are tight. A standard bay is ~2.5 m wide. The EV is 1.85 m. The lateral margin is roughly the localization error. Oblique detection is ugly. Approach at 30° and the bay opening is foreshortened, the divider lines are half-occluded by parked cars, depth gets noisy at grazing incidence. Reverse is the common case. Production stacks (Apollo, Autoware) treat it as a corner case. For parking it's most of the path. Jerk is rubric-policed. Anything above 2 m/s³ on engagement loses points, so I can't just stomp the brake the moment a path lands.

The "easy for humans, brutal for planners" regime. Everything below is an attempt to make the planner a little less brutalized.

02architecture

Eight nodes on a Jetson

The stack is eight ROS 2 nodes on a Jetson Orin, talking over DDS with intra-process zero-copy where I could get it.

fig 01 — chart10 hz / 50 hz
sensor · 60 hzStereo Camerasensor · 16 chLidar01 / detectYOLOv8 Detbay + obstacle02 / depthDepth Projectlidar as prior03 / fusion → gridSensor Fusion20 cm occupancy + bay polygons04 / plan · 2 hzHybrid A*Reeds-Shepp analytic expansion05 / control · 50 hzPID Trackerxtrack · heading · longitudinal06 / actuateCAN — steer · throttle · brake
fig 01Auto Park pipeline. Sensors merge into a fused costmap; planner runs at 2 Hz, control at 50 Hz.

Sensors run at their own cadence. Planner re-plans at 2 Hz unless an obstacle delta exceeds 0.3 m, in which case it preempts. Control is the only thing on a real-time scheduler.

03perception

Why fusion, after two failures

I tried three approaches before fusion stuck.

Pure lidar. Clean geometry, semantically blind. A bay is a flat patch of asphalt; so is a driving lane. I could see that there is room, not that it is a bay.

Pure RGB. Monocular YOLO finds bay lines beautifully in good light but has no metric scale. A 30-pixel line could be 3 m or 12 m away. Useless for planning.

Fusion. YOLOv8-s[5] on a custom dataset (~14k frames, ~9k synthesized in CARLA with randomized lighting, pavement, and bay orientation). Detector outputs four bay corners plus obstacle boxes. Stereo depth (Semi-Global Matching with lidar as a sparse prior to stabilize textureless asphalt) gives metric z per pixel. Fusion lifts each detection into a 3D polygon and rasterizes everything into a 20 cm occupancy grid with three channels: free, occupied, bay_candidate.

What the planner sees: a costmap and a list of candidate goal poses, one per bay, with confidence and orientation.

04planner

Hybrid A* is the heart

Grid A* fails for car-like robots because nodes don't carry heading. You get a path through cells, not a drivable one. The polyline ignores turning radius and demands heading changes the steering rack physically can't do.

Hybrid A*[1] lets nodes carry continuous state. Each node is (x, y, θ); expansions are short kinematic arcs at a fixed set of steering inputs ({−δ_max, −δ_max/2, 0, +δ_max/2, +δ_max}, forward and reverse). Children land off-grid; the discretization is only used for the closed set. Search is discrete, states are continuous.

The cost at each node:

(cost)

g(n) is arc length plus penalties (1.5x on reverse, 2.0x on direction changes — flipping gear is what hurts passengers most). h(n) is the larger of two:

h_nh, the non-holonomic-without-obstacles heuristic — length of the shortest Reeds-Shepp[2] curve to the goal, ignoring the costmap. Captures kinematics. h_ho, the holonomic-with-obstacles heuristic — length of the shortest grid-A* path, ignoring kinematics. Captures geometry. The max stays admissible and dominates either alone. Straight out of Dolgov et al.; I didn't invent it, just verified it works.

Near the goal, expanding kinematic arcs gets wasteful — you almost never land on the goal pose exactly. Instead I shoot a Reeds-Shepp analytic expansion directly to the goal and accept it as the tail if it's collision-free. Typical searches close 1,200 to 4,000 nodes for a parallel park versus 40k+ without.

fig 02 — chart~1.2k nodes vs 40k+
Hybrid A* expands short kinematic arcs from the start pose, building a branching search tree of about 1200 nodes. Near the goal, a single Reeds-Shepp analytic curve shoots from the deepest branch directly to the goal pose — replacing the 40k+ additional nodes a pure kinematic search would have needed.04 · hybrid a*kinematic arcs + 1 shootparkedparkedgoalstartwould need 40k+reeds-shepp shootidle0 nodeskinematic search + analytic tail
fig 02Hybrid A* expands kinematic arcs; one Reeds-Shepp shoot finishes the path.
05control

Three PIDs and a gear-flip story

The tracker is intentionally boring. Three PIDs at 50 Hz.

Cross-track drives lateral error e_y to zero, summed with a feedforward steering command from path curvature. Heading drives heading error e_ψ to zero; output sums into the same steering command. Longitudinal drives speed error to zero, with the setpoint from a precomputed velocity profile (slower at high curvature, zero at direction-change cusps).

Cross-track error against the closest path point:

(cross-track)

is the left-hand normal so sign carries which side of the path we're on.

The tuning story. Ziegler-Nichols on a flat straight gave me K_p = 0.8, K_i = 0.05, K_d = 0.12. Forward looked great. The moment I asked it to track a reverse arc it oscillated. Twice I almost blamed the planner before I noticed: the cross-track sign convention flips in reverse (the front axle becomes the "back" of the kinematic model). ZN had tuned for a regime that no longer applied.

Hand-tuned a second set for reverse (K_p = 0.45, K_i = 0.02, K_d = 0.18 — softer P, more D for damping) and switch on commanded gear. ZN is great when your plant is stationary; for parking the plant changes character every shift.

06validation

CARLA harness, 52 scenarios

Built a scenario harness on CARLA 0.9.15[4] with a small YAML DSL.

yaml// excerpt
# scenarios/oblique_30deg_wet_pedestrian.yaml
map: Town04
weather:
  precipitation: 0.6
  road_wetness: 0.8
  cloudiness: 0.7
ego:
  spawn: {x: 142.3, y: -88.1, yaw: 30.0}
  initial_speed: 2.0     # m/s, rolling start
target_bay:
  id: "bay_07"
  occupancy: empty
  neighbors: [occupied, occupied]
actors:
  - type: pedestrian
    spawn: {x: 138.0, y: -90.5}
    behavior: cross_at_t=4.0s
  - type: vehicle
    spawn: {x: 130.0, y: -85.0}
    behavior: static
metrics:
  - final_pose_error
  - max_lateral_deviation
  - max_jerk
  - planning_latency_p99

One scenario from the validation suite.

Across 52 scenarios — bay angles of 0, 15, 30, 45°, parallel and perpendicular, wet and dry, three pedestrian profiles — I got 8.1 cm RMSE on final pose translation and 2.3° on heading. p99 planning latency 187 ms, control jitter under 2 ms.

Honest caveat: the 8 cm is the mean across successful completions. Three scenarios timed out, all at 45° approach on a wet shoulder. Not in the average. Fixing them before competition.

fig 03 — chart8.1 cm rmse · 52 scenarios
A scatter of 52 CARLA scenarios plotted by final-pose error in centimetres. The 49 successful completions cluster left of the 25 cm rubric, with an RMSE of 8.1 cm marked by a dashed line. 3 45° wet- shoulder scenarios timed out and are stacked separately on the right — honestly excluded from the RMSE average.06 · validation52 scenarios8.1 cmfinal-pose rmse0 cm5 cm8.1 cm15 cm25 cmrubric8.1 cm rmse · n=49 successes3 timed out · 45° wetapproach0°15°30°45°successful completions only · honest exclusions visible
fig 03Final-pose error across 52 CARLA scenarios; three 45°-wet timeouts excluded from the RMSE.
07war story

The bug that ate three weeks

Most useful thing I learned, and it has nothing to do with algorithms.

Reverse-into-bay maneuvers kept failing at the end. Car would get 80% in, planner would emit an infeasible path, tracker would freeze or back out. I spent a week on Hybrid A* expansion granularity, a week on the cost function, a week suspecting localization drift.

The bug was in perception. Once the car was deep in the bay, the stereo cameras (A-pillar mounted, forward-biased FoV) lost sight of the rear and side markings. Fusion dutifully marked the bay low confidence. The planner — which I'd set to re-plan on every confidence drop — would invalidate the in-flight path and search from a mostly-uninformed costmap. It usually found something worse.

The fix was not algorithmic. I added a commit point: once the planner is within 1.5 m of the goal with heading error below 10°, the path is frozen and the tracker runs it open-loop, with lidar emergency-stop as the only override. An ugly finite-state-machine hack. Works perfectly.

fig 04 — chartre-plan storm → frozen tail
Two progress-vs-time traces of the same parking maneuver. The BEFORE trace climbs cleanly until t = 5 s then oscillates indefinitely as perception confidence drops trigger a re-plan loop, never reaching the goal. The AFTER trace climbs identically, then crosses a commit point at 1.5 m and 10° heading — the path is frozen and the tracker drives it open-loop to the goal by t ≈ 6.5 s.07 · commit pointbefore / afterbeforeafterprogressgoalgoalinfeasible · re-plan loopcommit · 1.5m / 10°frozen path02s4s6s8sre-plan storm · single commit · open-loop tail
fig 04A commit point freezes the path inside the goal window; tracker runs it open-loop.
08next

What's next

Three things on the roadmap.

Real sensor noise. CARLA stereo is too clean. Collecting ZED 2i data in the UWAFT lot to retrain the YOLO head and resample the depth-prior noise.

Latency budget on Orin. Fusion is 28 ms p50 on a desktop RTX 3070. I have 80 ms total at 10 Hz, which means TensorRT INT8 with QAT on the YOLO backbone and rewriting the rasterizer in CUDA.

Snow. Competition is in May, so probably fine, but the parking-line detector falls to 0.4 mAP under partial cover. Future me's problem.

Full stack runs at the SAE Autonomous Challenge in May 2027. I'll write a follow-up once we have real numbers — I expect 8 cm to roughly double once sensor noise, suspension compliance, and tire slip get involved. Still inside the 25 cm rubric.

/ footnotes

  1. [1]Dolgov, Thrun, Montemerlo, Diebel. "Path Planning for Autonomous Vehicles in Unknown Semi-structured Environments." IJRR 2010 — cs.cmu.edu/dolgov_path_planning.
  2. [2]Reeds & Shepp. "Optimal paths for a car that goes both forwards and backwards." Pacific J. Math, 1990 — msp.org/pjm/1990.
  3. [3]ROS 2 Humble documentation — docs.ros.org/en/humble.
  4. [4]CARLA Simulator 0.9.15 docs — carla.readthedocs.io.
  5. [5]Ultralytics YOLOv8 — docs.ultralytics.com.