Skip to content
← Projects
Embedded / Digital Design2026Complete

Real-Time Sobel Edge Detection on FPGA

A camera-to-display edge detection pipeline in Verilog on a Basys 3 — OV7670 capture, a streaming Sobel core producing one pixel per clock, and live 640×480 VGA output, verified bit-exact against a NumPy model.

  • Verilog
  • Vivado
  • Basys 3 / Artix-7
  • OV7670
  • VGA
  • Python / NumPy
  • Icarus Verilog

The problem

Sobel edge detection is trivial in software and interesting in hardware. On an FPGA you can't just index into an image — the pixels arrive as a stream, one per clock, and you need a 3×3 neighbourhood. The whole design problem is buffering exactly enough of the past to compute the present, and doing it fast enough to keep up with the camera without ever falling behind the display.

A two-person group project for CS4363 (Hardware Description Languages) at the University of Moratuwa, built with T.I.U. Gunasekara. Tested on the board with a live OV7670 feed as well as the built-in test pattern.

What it does

An OV7670 camera on the Basys 3's PMOD headers feeds a pipeline written entirely in Verilog on a Xilinx Artix-7 (xc7a35t), with live VGA output at 640×480, 60 Hz.

OV7670 → SCCB config → capture
       → RGB565 → grey
       → 2 line buffers → 3×3 window
       → Sobel core → threshold
       → frame buffer → VGA
  • The camera is configured over SCCB (an I²C-like bus) by a master written from scratch, which reads the line back during acknowledge bits and lights an LED if the camera never answers
  • Colour is converted to grey with BT.601 weights
  • The camera runs at 320×240 and each pixel is drawn as a 2×2 block to fill the screen
  • Switches choose between the raw feed, a binary edge map and the raw gradient magnitude; two buttons move the threshold live, and the LEDs show its value

The convolution never waits for a frame

The core of the design is a pair of Block RAM line buffers holding the previous two image rows. As each new pixel arrives, the buffers and a set of shift registers present a complete 3×3 window, and the Sobel core produces a new output every clock once the pipeline is full — three stages, three clocks of latency.

The ×2 kernel weights are wired shifts, so the core is nothing but adders: 53 LUTs, 94 flip-flops and zero DSP slices. Magnitude is |Gx| + |Gy| rather than a true square root — it over-estimates diagonal edges by up to ~41 %, but costs three adders instead of a multiplier and a square root, and the threshold absorbs the difference.

The single easiest thing to get wrong is the line buffer's latency. Its output is registered, so a buffer of length N delays by N + 1 clocks; the window therefore uses IMG_W − 1. Off by one, and the edge map looks almost right — which is exactly why a testbench checks every one of the nine taps instead of eyeballing a picture.

Three clocks that don't agree

The board's 100 MHz oscillator drives an MMCM for a 25 MHz pixel clock; the camera returns its own 12.5 MHz pixel clock. Those never line up, so the only crossings are two dual-clock frame-buffer BRAMs — structurally safe, write port on one clock and read port on the other — plus two-flop synchronisers for reset and the threshold. The constraints declare the clock trees asynchronous so the tools don't try to time paths between them.

Getting the camera clock onto the chip at all took some digging: the only pin available for it is the wrong half of a clock-capable pair, so Vivado refuses to place the design without an explicit routing exception — and that exception has to live in an implementation-only constraints file, because the net it names does not exist until synthesis creates it.

Runs without a camera

A built-in test-pattern generator feeds the pipeline at exactly the point the camera would, so a freshly programmed board shows live video with nothing plugged in. The card has one of everything the operator has to handle: a gentle gradient that should stay below threshold, a large rectangle, a bouncing circle that proves the picture is live, a checkerboard and a diagonal bar. An optional build bakes a photograph into Block RAM instead.

Verification

The design is verified bottom-up, so a failure points at one module rather than "the picture looks wrong". Nine testbenches, all passing:

  • Kernel arithmetic against hand-computed windows — flat field, ideal edges, impulse, threshold from both sides
  • Windowing on an image whose every pixel value is its own index, so each tap has a closed-form expectation
  • Whole image streamed through the pipeline and compared with a bit-exact NumPy golden model76,800 / 76,800 pixels match at 320×240
  • Camera protocol and capture against a behavioural OV7670 model, with the SCCB write reconstructed from the bus itself
  • VGA timing counted over a whole frame — 307,200 active pixels at 59.52 Hz
  • Full system in both camera and camera-free modes, including a check that every 2×2 screen block is uniform, which proves the upscaling is aligned

The whole suite also runs under open-source tools — Icarus Verilog plus Python — in one command, with no Vivado licence needed.

Results

Post-implementation, camera-free build:

  • Logic: 658 of 20,800 LUTs (3.2 %) and 599 of 41,600 registers (1.4 %)
  • Block RAM: 28.5 of 50 tiles (57 %) · DSP slices: 3 of 90
  • Timing: met with +26.9 ns of worst-case slack at 25 MHz — roughly 76 MHz Fmax
  • Power: 0.2 W on-chip

Almost all of that Block RAM is the two frame buffers, which exist only to cross clock domains. The convolution's entire working memory is two small RAM tiles. Even the Fmax figure understates the pipeline: every one of the ten worst timing paths sits in the test card's circle-drawing logic, not in the image processing.

Starting point

The idea and the OV7670 register approach come from Angelo Jacobo's ULX3S Sobel design, but this is a re-implementation for a different architecture rather than a port — Xilinx instead of Lattice, on-board VGA instead of HDMI, on-chip RAM instead of external SDRAM, a single streaming pipeline instead of a split-kernel state machine, and a full testbench suite where the reference had hardware testing only.

The same operator, on a GPU

I've since built the same 3×3 convolution as a WebGL2 fragment shader — it runs live on your camera, in the browser, no server involved. Worth clicking, because the comparison is the point.

Almost nothing carries over. On the FPGA there is no image: pixels arrive one per clock and a 3×3 neighbourhood only exists because block RAM is holding the previous two rows. That line buffer, plus reconciling the clock domains around it, is the design. On a GPU the frame is already in memory and every output pixel reads whatever it likes, so the hard part simply isn't there — and instead every pixel is computed at once, which the FPGA cannot do.

Sobel in Verilog on an FPGA, in C on a CPU, and in GLSL on a GPU are three genuinely different engineering problems wearing the same fifteen multiply-accumulates. That is most of why the algorithm is worth building more than once.