California Polytechnic State University, San Luis Obispo

TSMG · 2026 Research Program

Depth-Guided Computer Vision for Precision Agricultural Fertilizer Application

Ivan Torriani Lead Researcher, Dr. Fahim Khan Principal Investigator
California Polytechnic State University, San Luis Obispo

Offline validation run. The frame is partitioned into three horizontal nozzle zones; each zone reports its live canopy coverage ratio and switches to Active once coverage exceeds 25%. Zone state maps directly to GPIO output on the Jetson.


Abstract

Broadcast fertilizer application treats a field as uniform, delivering the same rate to bare soil that it delivers to dense canopy. The waste is both economic and environmental, and the fix — applying only where plant material actually is — requires a perception system that runs in real time on a moving implement, outdoors, without a network connection or a depth sensor budget.

This work develops a perception-to-actuation pipeline that resolves canopy presence spatially and drives physical nozzles accordingly, using a single RGB camera on an NVIDIA Jetson. Monocular depth is estimated with a Dense Prediction Transformer (MiDaS DPT), which yields a relative inverse-depth map at frame rate without stereo rigs or LiDAR. Because that map is affine-invariant rather than metric, we introduce a single-reference calibration procedure that recovers an approximate physical distance from a power-law fit, giving the system a usable proximity signal from one measurement at a known 2 ft standoff. The depth map serves a second purpose: thresholded and morphologically cleaned, it suppresses background clutter before detection, so distant vegetation outside the treatment row cannot trigger a nozzle.

Canopy is then localized by a YOLOv8 detector fine-tuned on a custom canopy dataset. Rather than targeting individual plants — which would demand actuation precision the hardware does not have — detections are reduced to a union mask and integrated over three horizontal zones aligned with the physical nozzle bar. A zone opens when canopy covers more than 25% of it. This zone abstraction is the central design choice of the system: it degrades gracefully under partial or noisy detection, and it maps onto a handful of GPIO lines rather than a servo-aimed boom.

The full pipeline runs per frame on the Jetson under FP16 with fused detector layers, driving three GPIO outputs that correspond one-to-one with nozzle zones. We document the hardware bring-up path that produced this design, including an earlier two-pin combinatorial encoding that was replaced after it proved unable to represent simultaneously active zones without transient conflicts.


How It Works

PERCEPTION Camera frame imutils VideoStream, BGR MiDaS DPT dpt_swin2_tiny_256 Inverse depth map bicubic to frame size Background mask threshold + morphology DETECTION & INTEGRATION YOLOv8 detector canopies_latest.pt Detection union mask binary, class 0 Zone coverage 3 bands, ratio > 0.25 GPIO output BOARD 7 / 33 / 35 ACTUATION Three fertilizer nozzles one per vertical zone Depth gating of the detector input is optional: --use_depth_mask, --detect_on_depth_mask

Figure 1. End-to-end pipeline. A single RGB frame is passed through MiDaS DPT to obtain a relative inverse-depth map, which is upsampled bicubically back to native resolution. That map optionally suppresses background before the YOLOv8 canopy detector runs. Detections are collapsed into a binary union mask, integrated over three horizontal zones, and thresholded at 25% coverage to produce three independent GPIO signals. Every stage executes once per frame inside a single torch.no_grad() loop.


Research Subsystems

2.1Monocular Depth Estimation

Depth is estimated with MiDaS, a monocular framework that produces relative inverse depth from a single RGB image. We use the Dense Prediction Transformer (DPT) architecture, which pairs a transformer encoder with a convolutional decoder: features are extracted from four intermediate transformer layers and fused through successive Reassemble and RefineNet blocks, then progressively upsampled and passed through a convolutional head. The output is constrained non-negative by a ReLU.

Two variants were evaluated. The BEiT-Large model served as a high-accuracy reference during development; the Swin V2 Tiny model is what actually deploys to the Jetson, where its 256 × 256 square input and far smaller backbone are what make frame-rate operation possible.

Table 1. Evaluated DPT model variants.

Model identifierBackboneInput resolution Aspect ratio preservedRole
dpt_beit_large_512BEiT ViT-L/16 512 × 512YesHigh-accuracy reference
dpt_swin2_tiny_256Swin Transformer V2 Tiny 256 × 256No (square crop)Jetson deployment

Inference runs under torch.no_grad(). On CUDA hardware the model is cast to half precision and moved to torch.channels_last memory layout, which reduces memory bandwidth on the Jetson's shared-memory architecture. The raw prediction is upsampled back to native frame resolution with bicubic interpolation before any downstream use, so the depth map and the RGB frame remain pixel-aligned.

Table 2. Input preprocessing and inference parameters.

StepParameterValue
Resize methodModeMinimal (pad to multiple of 32)
NormalizationPer-channel mean[0.5, 0.5, 0.5]
NormalizationPer-channel std[0.5, 0.5, 0.5]
Output upsampleInterpolation modeBicubic
FP16 optimizationMemory layouttorch.channels_last
Gradient computationContexttorch.no_grad()
prediction = model.forward(sample)
prediction = (
    torch.nn.functional.interpolate(
        prediction.unsqueeze(1),
        size=target_size[::-1],
        mode="bicubic",
        align_corners=False,
    )
    .squeeze()
    .detach()
    .cpu()
    .numpy()
)
src/midas_scripts/run_combined.py

Limitations:

  • DPT output is affine-invariant. Absolute scale is not recoverable from the network alone, which motivates the calibration in §2.2.
  • The Swin V2 Tiny variant does not preserve aspect ratio; non-square frames are distorted before inference, and the distortion varies with camera mounting.
  • Depth is re-estimated independently every frame. There is no temporal filtering, so per-frame depth noise propagates directly into the masking stage.

2.2Depth-to-Distance Calibration

MiDaS returns relative inverse depth, not metres or feet. To obtain a usable proximity signal we calibrate against a single reference measurement: an object placed at a known 2 ft standoff, with the maximum depth value across the frame recorded at that distance. The ratio of the two yields a linear scale factor.

metric = SCENE_1_SHIFT × dmax where SCENE_1_SHIFT = 0.00176834659 ft per depth unit, derived as 2 ft ÷ dmax at the calibration distance.

A purely linear model is a poor fit, because inverse depth and physical distance are related non-linearly — roughly D = 1 / (ad + b). We therefore refine the estimate with a power law anchored at the same reference point:

Drefined = 2.0 × ( dmax, scene 1 / dmax ) s with dmax, scene 1 = 1130.5822 (the calibrated reference depth) and sensitivity exponent s = 3.2, tuned empirically. The exponent controls how sharply estimated distance falls off with depth, and was chosen to maximize resolution at close range where canopy proximity actually matters.

Table 3. Calibration constants.

ParameterSymbolValueDescription
Scene shift constantSCENE_1_SHIFT0.00176834659 Linear scale factor (ft / depth unit) at the 2 ft reference
Reference depth valuedmax_scene_11130.5822 Maximum depth output recorded at calibration distance
Reference distance2.0 ft Physical standoff used for calibration
Sensitivity exponentsensitivity3.2 Power-law exponent for the refined distance estimate

A binary proximity conclusion is derived from the first-order estimate: when the reflected metric value falls below zero — indicating the object is nearer than the calibration reference — the frame is labelled “Object Detected Close to Camera.”

Limitations:

  • Calibration rests on a single reference measurement at one distance. The power-law exponent is fitted by hand rather than regressed over a distance sweep, so error away from 2 ft is uncharacterized.
  • Constants are scene-specific. Changing camera, lens, or mounting height invalidates SCENE_1_SHIFT and requires re-calibration.
  • Using frame-wide dmax makes the estimate sensitive to a single bright or near outlier pixel anywhere in view.

2.3Depth-Based Background Segmentation

Field scenes contain vegetation well outside the treatment row. To stop that clutter from reaching the detector, pixels whose inverse depth falls below a fixed threshold are classified as background and replaced with solid black. The raw threshold mask is noisy, so it is cleaned with a morphological opening (removing isolated foreground speckle) followed by a closing (filling small holes inside the canopy region).

Table 4. Depth masking parameters.

ParameterValuePurpose
Depth threshold1130.5822 Foreground / background boundary in inverse depth units
Morphological kernel5 × 5 (ones) Structuring element for opening and closing
Morphological openingApplied first Removes small foreground noise
Morphological closingApplied second Fills small holes in the foreground mask
Background fillRGB = [0, 0, 0] Replaces background pixels with black
background_mask = (depth_map < depth_threshold).astype(np.uint8)
kernel = np.ones((5, 5), np.uint8)
background_mask = cv2.morphologyEx(background_mask, cv2.MORPH_OPEN, kernel)
background_mask = cv2.morphologyEx(background_mask, cv2.MORPH_CLOSE, kernel)

foreground_mask = 1 - background_mask
bg = np.full_like(img_rgb, bg_color, dtype=img_rgb.dtype)
result_rgb = np.where(foreground_mask[..., None] == 1, img_rgb, bg)
src/midas_scripts/run_combined.py — solid_background()

Masking is deliberately decoupled from detection. Two independent flags control it: --use_depth_mask produces the masked frame, while --detect_on_depth_mask decides whether the detector consumes the masked frame or the raw one. This lets masking be evaluated as a display aid and as a detection gate separately, without rebuilding the pipeline.

Limitations:

  • The threshold is a fixed constant, not adaptive. Under lighting or scene changes that shift the depth distribution, it will clip foreground or admit background.
  • Masking to black creates hard artificial edges. The detector was not fine-tuned on masked imagery, so these edges are out of distribution for it.
  • A 5 × 5 kernel is small relative to canopy structure; thin stems and leaf tips are frequently removed by the opening step.

2.4Canopy Detection

Canopy localization uses a YOLOv8 detector fine-tuned on a custom canopy dataset. At load time the model is moved to GPU, its Conv+BatchNorm layers are folded together via model.fuse(), and inference is run in half precision — both throughput optimizations that matter on a power-constrained Jetson.

Table 5. Detector configuration.

ParameterValueDescription
Weightscanopies_latest.ptCustom-trained canopy detector
Confidence threshold0.5Minimum score to accept a detection
Target class0 (canopy)Only this class drives actuation
Inference precisionFP16Half-float on CUDA
Layer fusionEnabledmodel.fuse() merges Conv+BN
DeviceCUDA (GPU 0)Falls back to CPU if unavailable

Every accepted box is rasterized into a shared binary mask. This union representation is what makes the downstream logic robust: overlapping detections of the same plant contribute their area once rather than double-counting, and the zone integrator never needs to reason about individual box identity.

Limitations:

  • Axis-aligned boxes over-estimate area for sparse or diagonally-oriented canopy, inflating the coverage ratio relative to true leaf area.
  • A single fixed confidence threshold of 0.5 is applied regardless of zone, range, or lighting.
  • Detection is per-frame with no tracking, so a plant that flickers below threshold for one frame briefly closes its nozzle.

2.5Spatial Zone Analysis

The frame is divided into three equal horizontal bands aligned with the physical nozzle bar. For each band we compute the fraction of its pixels that lie inside at least one detection:

coveragei = (detected pixels in zone i) / (total pixels in zone i) Zone i is marked Active when coveragei > 0.25. The threshold was set empirically to trade sensitivity against false activation from partial or distant canopy.

Table 6. Nozzle zone definitions.

ZoneLabelRow rangeFraction of frame height
1Top0 to H/3Upper third
2MiddleH/3 to 2H/3Middle third
3Bottom2H/3 to HLower third
FRAME PARTITION ZONE STATE GPIO (BOARD) Nozzle 1 — Top coverage 41.6% Nozzle 2 — Middle coverage 12.3% Nozzle 3 — Bottom coverage 68.9% ACTIVE > 0.25 INACTIVE ≤ 0.25 ACTIVE > 0.25 pin 7 → HIGH pin 33 → LOW pin 35 → HIGH

Figure 2. Zone integration and pin mapping. Coverage is integrated independently per band, so a densely covered bottom zone and an empty middle zone produce independent nozzle decisions in the same frame. Coverage values shown are illustrative of the decision rule, not measured results.

region_area = region_h * w
covered_pixels = int(det_mask[y1_area:y2_area, :].sum())
coverage_ratio = covered_pixels / max(region_area, 1)

coverage_ratios[i] = coverage_ratio
region_active[i] = coverage_ratio > coverage_threshold
src/midas_scripts/run_combined.py — compute_region_activity()

Limitations:

  • Zones are fixed equal thirds of the image, not calibrated to the physical spray geometry of the nozzle bar. The mapping from image row to ground position depends on camera pitch and height, which are not modelled.
  • A hard 25% threshold produces chatter when coverage hovers near the boundary; no hysteresis or debounce is applied.
  • Coverage is a pure area statistic — it cannot distinguish one dense plant from several sparse ones within a zone.

2.6GPIO Actuation and Hardware Bring-Up

Actuation is the point where perception becomes physical. Output runs on an NVIDIA Jetson through Jetson.GPIO using the BOARD pin convention, which addresses physical connector positions rather than SoC GPIO numbers. Pins initialize LOW, and a cleanup routine drives them LOW and releases the lines on exit — including on exception — so a crash cannot leave a nozzle latched open.

Bring-up progression

Before touching the detection pipeline, GPIO behaviour was validated by a series of standalone scripts, each isolating one unknown.

Table 7. GPIO validation script progression.

StageScriptLibraryPinsBehaviourPurpose
1led_test2.pygpiod (v2 API) 1 (offset 144)Single HIGH/LOW toggle, 2 s intervals Confirm basic electrical continuity
2ledtest3.pygpiod (legacy API) 1 (line 105)Continuous 1 s toggle loop Verify sustained operation
3ledtest4.pyJetson.GPIO 1 (BOARD 7)Continuous 1 s toggle loop Confirm Jetson.GPIO and BOARD convention
4ledtest5.pyJetson.GPIO 2 (BOARD 7, 33)Keyboard-driven via curses Validate independent two-channel control

v1 — two-pin combinatorial encoding

The first integrated design had only two output lines available and needed to represent three zones. It used a combinatorial encoding, where zone identity is carried by the pair of pin states rather than by a dedicated line:

Zone activePin 1 (BOARD 7)Pin 2 (BOARD 33)
TopHIGHHIGH
MiddleHIGHLOW
BottomLOWHIGH
NoneLOWLOW

The encoding is unambiguous only while exactly one zone is active. With multiple zones active it degenerates: the intended resolution is a logical OR (pin1 = top OR middle, pin2 = top OR bottom), which means top alone and middle + bottom are indistinguishable at the pins. Worse, the implementation wrote pins sequentially rather than in a single pass:

if top_active:
    GPIO.output(led_pin1, GPIO.HIGH)
    GPIO.output(led_pin2, GPIO.HIGH)
if middle_active:
    GPIO.output(led_pin1, GPIO.HIGH)
if bottom_active:
    GPIO.output(led_pin2, GPIO.HIGH)
if (not middle_active):
    GPIO.output(led_pin1, GPIO.LOW)
if (not bottom_active):
    GPIO.output(led_pin2, GPIO.LOW)
src/led_scripts/nozzle_sim_led.py — v1, superseded

Because the trailing clears are unconditional on top_active, a frame in which only the top zone is active first drives both pins HIGH and then immediately pulls both LOW — the top zone can never actually hold its output. This class of failure is intrinsic to resolving a shared line across sequential writes.

v2 — three-pin direct mapping (deployed)

The deployed system allocates one line per zone. Zone state and pin state become identical, all three pins are written in a single pass per frame, and every one of the eight multi-zone combinations is representable without conflict.

SignalBOARD pinZoneDrives
led_pin17TopNozzle 1
led_pin233MiddleNozzle 2
led_pin335BottomNozzle 3
top_active, middle_active, bottom_active = region_active

GPIO.output(led_pin1, GPIO.HIGH if top_active else GPIO.LOW)
GPIO.output(led_pin2, GPIO.HIGH if middle_active else GPIO.LOW)
GPIO.output(led_pin3, GPIO.HIGH if bottom_active else GPIO.LOW)
src/midas_scripts/run_combined.py — write_gpio_states()

Design note. The v1 encoding was not merely a bug — it was a correct response to a two-line constraint that stopped being true. Adding a third line removed the need for encoding entirely, which is why the deployed logic is three unconditional writes and no truth table. The scripts remain in src/led_scripts/ as the hardware validation record.

Limitations:

  • GPIO lines are logic-level signals, not nozzle drivers. Solenoid switching, flyback protection, and valve actuation latency sit outside this system.
  • Pins are written every frame with no rate limiting, so a zone oscillating near the coverage threshold switches its output at frame rate.
  • There is no readback or fault detection: the software cannot tell whether a nozzle actually responded to a commanded state.

Real-Time Inference Loop

All subsystems execute in sequence inside one continuous frame loop, with camera capture handled by imutils.VideoStream on a separate thread to avoid blocking on I/O. The entire loop body runs under a single torch.no_grad() context, and cuDNN benchmark mode is enabled at startup so the runtime can select optimized convolution algorithms for the fixed input resolution.

Table 8. Per-frame processing pipeline.

StepOperationNotes
1Camera frame captureBGR via threaded VideoStream
2Colour conversion and normalizationBGR → RGB, scaled to [0, 1]
3MiDaS depth inferenceBicubic upsample to native resolution
4Depth-based background maskingOptional, --use_depth_mask
5YOLOv8 canopy detectionRaw or depth-masked frame
6Detection union maskBinary mask over all accepted boxes
7Per-zone coverage computationThree horizontal bands
8Zone activation and GPIO outputSingle-pass write to three pins
9Annotated frame renderingOverlays, labels, coverage percentages
10Optional video writeAppended to output MP4

Throughput is tracked with an exponential moving average (α = 0.1) rather than an instantaneous reading, so the reported rate is not dominated by single-frame jitter:

now = time.time()
dt = max(now - last_time, 1e-6)
fps_ema = 0.9 * fps_ema + 0.1 * (1.0 / dt)
last_time = now
src/midas_scripts/run_combined.py

For diagnostics the annotated RGB frame can be rendered beside a normalized depth heatmap (OpenCV COLORMAP_INFERNO, brighter = closer). Active zones are filled with a semi-transparent green overlay (α = 0.3) and outlined green; inactive zones are outlined red; detection boxes are drawn blue. This annotated stream is what is written to disk for post-hoc review of each experimental run.


Tech Stack

Deep learning
torch ≥ 2.0.0 torchvision ≥ 0.15.0 CUDA cuDNN FP16
Depth estimation
MiDaS DPT timm ≥ 0.9.0 Swin Transformer V2 BEiT ViT-L/16
Object detection
ultralytics ≥ 8.0.0 YOLOv8
Computer vision
opencv-python ≥ 4.8.0 numpy ≥ 1.24.0 imutils ≥ 0.5.4
Hardware & I/O
NVIDIA Jetson Jetson.GPIO ≥ 2.1.0 libgpiod curses

Team

Lead Researcher — depth estimation pipeline, calibration procedure, zone integration logic, GPIO actuation, and Jetson deployment.
Principal Investigator — research direction and supervision.

Documentation & Resources

References

  • Ranftl, R., Lasinger, K., Hafner, D., Schindler, K., & Koltun, V. (2020). Towards robust monocular depth estimation: Mixing datasets for zero-shot cross-dataset transfer. IEEE TPAMI.
  • Ranftl, R., Bochkovskiy, A., & Koltun, V. (2021). Vision Transformers for Dense Prediction. ICCV 2021.