Synthesizable VHDL for the ORB-SLAM3 feature-extraction front end, targeting the
programmable logic of an AMD Kria KR260 (Zynq UltraScale+ MPSoC, xck26-sfvc784-2LV-c).
The accelerator takes over FAST detection, non-maximum suppression, corner scoring and
keypoint orientation from ORBextractor. The processing system streams one image-pyramid
level at a time into it over AXI DMA and reads back packed 128-bit keypoints.
Written for a Master's thesis at the University of the Bundeswehr Munich. The work was carried out during a research visit to San Diego State University.
This repository holds the RTL, its testbenches, the non-regenerable Vivado project sources, and the hand-written files the Kria needs to load the accelerator. The generated bitstreams are not here, and neither is the software side.
The matching ORB-SLAM3 fork lives on the kr260_hw_accelerator branch of
brdsdsu/ORB_SLAM3. It
holds the userspace driver (src/ORBHwAccelerator.cc), the ORBextractor changes that call
into the hardware, and the stereo two-instance path. What it needs from this design is the
interface contract documented below: the AXI4-Lite register map, the 128-bit keypoint
layout, and the build ID that couples them.
| Target | AMD Kria KR260, xck26-sfvc784-2LV-c |
| PL clock | 125 MHz, single clock domain (stream clock = fabric clock = AXI-Lite clock) |
| Interfaces | AXI4-Stream slave (8-bit pixels), AXI4-Stream master (128-bit keypoints), AXI4-Lite slave (control and status) |
| Language | VHDL-2008, plus a VHDL-93/2002 wrapper required by the Vivado IP packager |
| Detection | FAST-9 at the permissive threshold, 3x3 non-maximum suppression on the FAST score |
| Scoring | FAST score or Harris response, selected at elaboration via G_SCORE_TYPE |
| Orientation | intensity centroid over a 31x31 circular patch, CORDIC atan2 |
| Build ID | 0xC0DE0003 (readable at AXI-Lite offset 0x1C) |
Generic defaults are sized so that two instances fit on a K26, which is what a stereo configuration needs.
The design tracks OpenCV and ORB-SLAM3 semantics closely enough that the hardware keypoint
set can be compared against the software one directly: FAST-9 with nonmaxSuppression=true,
HARRIS_SCORE and FAST_SCORE as in ORBextractor.h, IC_Angle() moments with the
ORB-SLAM3 u_max table, and the EDGE_THRESHOLD and 35-pixel cell grid from
ORBextractor.cc.
AXI4-Stream slave (8-bit pixels, raster order, from AXI DMA MM2S)
|
v
+--------------+
| window_NxN | 31x31 sliding window, 30 line buffers, 1-cycle latency
+--------------+
| |
| +--------------------+
v v
+--------------+ +-----------------+
| fast9_core | latency 3 | fast_nms | latency 5
| detect only | | fast_response |
+--------------+ | + 3x3 local max |
| +-----------------+
| |
+--------> push gate <--------+ corner AND local max AND
| not suppressed by the cell gate
v
+---------------------+
| orientation_top |
| +---------------+ |
| | corner_fifo | | 31x31 patch + coords, URAM + BRAM,
| +---------------+ | zero-drop via backpressure
| +---------------+ |
| | moment_proc. | | m10 / m01, 23 cycles
| | harris_resp. | | 7x7 Sobel Harris, 19 cycles (G_SCORE_TYPE=0)
| +---------------+ |
| +---------------+ |
| | cordic_atan2 | | 22 iterations, 24-cycle latency
| +---------------+ |
+---------------------+
|
v
+-------------+
| TOP | AXI4-Lite registers, keypoint FIFO,
+-------------+ 128-bit packing, EOF sentinel
|
v
AXI4-Stream master (128-bit keypoints, to AXI DMA S2MM)
Everything inside extractor_top is aligned to T + ALIGN_LAT (5 cycles after the window
output). The sub-module latency constants are cross-checked by static asserts and by a
simulation-time self-check against the actual valid chains, so a latency change in one
module fails the next testbench run instead of silently misaligning the pipeline.
window_NxN receives the image dimensions on ports rather than generics, so a single
instance serves every pyramid level. Only the line-buffer depth is fixed at elaboration,
via MAX_WIDTH, sized for level 0.
Two mechanisms keep the corner FIFO from overflowing:
- Backpressure.
o_prog_fullis wired into the pixel-streamtreadygate inTOP, so the input stalls before the FIFO can fill. Tail drops became impossible. The cost is measured inSTALLCNT(0x2C), andDROPCNT(0x18) should stay at zero. - Strict-cell gate. A permissive corner is discarded when the ORB-SLAM3 selection cell
it falls into already holds a strict corner, because the per-cell selection on the
software side would discard it anyway. The grid math replicates
ORBextractor.cc(EDGE_THRESHOLD = 19, nominal cell width 35). Suppressed corners are counted inSUPPCNT(0x28). Writing zero toCELLDIMandCELLNUMleaves the gate inert.
src/ synthesizable RTL
testbenches/ self-checking testbenches and their Python vector generators
vivado/ block design and packaged IP definitions
deploy/ device-tree overlay source and the xmutil app descriptor
| File | Role |
|---|---|
TOP.vhd |
AXI wrapper: AXI4-Lite register file, per-frame control FSM, output keypoint FIFO, 128-bit packing, EOF sentinel |
TOP_vivado_wrapper_vhdl_2002.vhd |
VHDL-93/2002 entity orb_feature_top, forwards generics and ports 1:1. UG1118 requires the designated top-level IP file to be plain VHDL |
extractor_top.vhd |
Level-3 extractor: window, detection, NMS, strict-cell gate, orientation |
window_NxN.vhd |
Sliding NxN window from a raster stream, (N-1) line buffers plus an NxN register array |
fast9_core.vhd |
FAST-9 decision on the 16-pixel Bresenham circle, 3-stage pipeline |
fast_response.vhd |
FAST score, equivalent to OpenCV cornerScore, 4-stage pipeline |
fast_nms.vhd |
3x3 non-maximum suppression on the FAST score field, reusing the same 31x31 patch, so it costs no line buffers |
harris_response.vhd |
Harris response over the 7x7 block, matching OpenCV HarrisResponses() with k = 0.04 |
corner_fifo.vhd |
Synchronous FIFO carrying the full orientation patch, circle-masked storage, UltraRAM plus BRAM |
moment_processor.vhd |
Pipelined m10 and m01 for the intensity centroid, bit-exact against the sequential reference |
orientation_top.vhd |
Drains the corner FIFO, runs moments and Harris in parallel, emits enriched keypoints |
cordic_atan2.vhd |
Pipelined vectoring-mode CORDIC, four-quadrant, same sign convention as cv::fastAtan2() |
axi_axcache_override.vhd |
Combinational shim forcing AxCACHE = 1111 and AxPROT = 010 so DMA traffic through S_AXI_HPC0_FPD is snooped by the CCI |
feature_pkg.vhd |
Universal types: pixel_t, coord_t, patch_arr_t, ceil_log2. Kept VHDL-93/2002 clean because the packaging wrapper references it |
fast_pkg.vhd |
FAST-specific types and circle-extraction helpers |
umax_pkg.vhd |
u_max half-width table for the circular orientation patch, built at elaboration exactly as ORB-SLAM3 builds it |
32-bit registers, byte offsets from the base address of the block.
| Offset | Name | Access | Contents |
|---|---|---|---|
0x00 |
CTRL |
RW | [0] enable, [1] soft reset |
0x04 |
STATUS |
RO | [0] busy, [1] done, [2] overflow, [3] cfg_error |
0x08 |
WIDTH |
RW | image width of the current pyramid level |
0x0C |
HEIGHT |
RW | image height of the current pyramid level |
0x10 |
THRESH |
RO | [7:0] permissive threshold, [15:8] strict threshold, [16] score type, all as synthesized |
0x14 |
KPCOUNT |
RO | real keypoints emitted this frame |
0x18 |
DROPCNT |
RO | corner-FIFO drops, expected to stay 0 since the backpressure gate |
0x1C |
ID |
RO | build ID, currently 0xC0DE0003 |
0x20 |
CELLDIM |
RW | [15:0] wCell, [31:16] hCell |
0x24 |
CELLNUM |
RW | [15:0] nCols, [31:16] nRows |
0x28 |
SUPPCNT |
RO | corners suppressed by the strict-cell gate |
0x2C |
STALLCNT |
RO | cycles the pixel input stalled on corner-FIFO backpressure |
Per-frame sequence driven by the PS: write WIDTH and HEIGHT (and the cell grid) for the
level, set CTRL[0], wait for STATUS[1], clear CTRL[0], repeat for the next level. The
control FSM holds the core in reset while idle, so every level starts from clean counters.
ID is the compatibility contract with the driver, and the keypoint layout below is coupled
to it. Bump the build ID whenever the layout changes. THRESH[16] lets the driver verify at
probe time that the loaded bitstream was built with the score type it expects.
| Word | Bits | Field |
|---|---|---|
| 0 | [15:0] |
x (11 bits used) |
| 0 | [31:16] |
y (11 bits used) |
| 1 | [63:32] |
response, int32. FAST score 0..255 zero-extended when G_SCORE_TYPE=1, signed Harris response when G_SCORE_TYPE=0 |
| 2 | [87:64] |
angle, 24-bit two-complement, +2^22 corresponds to +pi |
| 2 | [88] |
is_brighter |
| 2 | [89] |
passed_strict |
| 2 | [95:90] |
reserved, zero |
| 3 | [127:96] |
0x00000000 for a real keypoint, 0xFFFFFFFF for the end-of-frame sentinel |
The dual-threshold scheme mirrors the iniThFAST and minThFAST adaptation in ORB-SLAM3.
Detection always runs at the permissive threshold, and each keypoint carries passed_strict,
so the software reproduces the two-pass behavior without a second hardware pass. Detection
and NMS are FAST-based in both scoring modes, so the corner set never depends on
G_SCORE_TYPE. Only the ranking value does.
The testbenches are self-checking and were developed under ModelSim and QuestaSim. They also
analyze and run under GHDL. Compile the packages first (feature_pkg, fast_pkg,
umax_pkg), then the module under test, then its testbench.
The testbenches open their vector files by paths relative to the repository root, for example
testbenches/cordic_vectors.hex, so run the simulator from the repository root and the
generators from inside testbenches/.
The stimulus and golden-output files (*.hex, *expected*.txt) are not in the
repository. They amount to roughly 78 MB of generated data and are reproduced from the
Python generators that are tracked here. Each generator is the golden model for its module,
so generator and testbench always belong together.
python -m venv .venvpip install -r testbenches/0_requirements.txtThe generators are golden models, so 0_requirements.txt pins exact versions rather than
leaving them open: a change in numpy integer or rounding behavior would alter the golden
data silently instead of failing. The thesis vectors were produced with numpy 2.4.6 and
tqdm 4.67.3 on CPython 3.14.5. Only numpy and tqdm are listed, because the remaining
imports are standard library modules that pip has nothing to install for. The leading 0
in the filename exists purely to sort it above the files it describes.
cd testbenches && python window_NxN_generator.py && python fast9_core_generator.py && python fast_response_generator.py && python fast_nms_generator.py && python harris_response_generator.py && python moment_processor_generator.py && python cordic_atan2_generator.py && python orientation_top_generator.py && python extractor_top_generator.py && python pyramid_tb_generator.py && python top_tb_generator.pyEvery generator takes -h. Image size, seed, thresholds and score type are parameters, for
example python extractor_top_generator.py -W 256 -H 224 --score-type harris. Each one also
writes a *_test_params_pkg.vhd, which is tracked, so the parameters a given run used
remain visible in the repository even without the vectors.
| Testbench | Covers |
|---|---|
window_NxN_tb.vhd |
window and line-buffer behavior, including line wraps |
fast9_core_tb.vhd |
FAST-9 decision against the software oracle |
fast_response_tb.vhd |
FAST score against OpenCV cornerScore |
fast_nms_tb.vhd |
3x3 suppression, including tie handling |
harris_response_tb.vhd |
Harris response against HarrisResponses() |
moment_processor_tb.vhd |
m10 and m01 bit-exactness |
cordic_atan2_tb.vhd |
angle error against fastAtan2 over all four quadrants |
orientation_top_tb.vhd |
FIFO drain, moment and CORDIC handshakes, keypoint output |
corner_fifo_tb.vhd |
fill, drain, programmable-full and drop behavior |
extractor_top_tb.vhd |
end-to-end extractor on a synthetic image |
extractor_pyramid_tb.vhd |
one instance driven across all pyramid levels |
TOP_tb.vhd |
AXI4-Lite transactions, per-frame FSM, packing and EOF sentinel |
umax_pkg_tb.vhd |
elaboration-time u_max table |
extractor_top_tb and extractor_pyramid_tb take the expected-output file as a generic, so
the same testbench covers both scoring modes:
-gG_USE_HARRIS=true -gG_EXPECTED_FILE=testbenches/extractor_expected_harris.txtfast_score_consistency.py cross-checks the FAST score model against the detection model
and is not tied to a single testbench.
The RTL is added to the Vivado block design by path as a module reference, so src/ stays
the single source of truth and there is no mirrored copy inside the Vivado project.
When packaging as a custom IP the file types matter. UG1118 requires the designated
top-level file to be plain Verilog or VHDL-93/2002, which is why
TOP_vivado_wrapper_vhdl_2002.vhd (entity orb_feature_top) exists. Mark that file and
feature_pkg.vhd as VHDL, everything else as VHDL 2008.
G_CORE_URAM_COLS = 32 gives 32 URAM plus 52 BRAM36 per instance, sized so two instances
fit the K26. G_KP_FIFO_DEPTH must be a power of two, because the pointer wrap relies on it,
and G_PROG_FULL_GAP must be at least G_CORE_FIFO_DEPTH plus the pipeline tail. Both
constraints are checked by asserts at elaboration.
Only the parts of the Vivado project that a build cannot regenerate. Synthesis runs, implementation checkpoints, the IP cache and the generated output products are excluded.
| File | Role |
|---|---|
create_project.tcl |
recreates the project: part xck26-sfvc784-2LV-c, board xilinx.com:kr260_som:part0:1.1, filesets, run settings. Written with write_project_tcl -no_copy_sources -use_bd_files, so it references ../src and ../testbenches rather than copying them |
HW_Acc.bd |
the block design: two accelerator instances, their AXI DMAs on separate PS HP ports, the SmartConnects, the AxCACHE shim and the PS configuration. Authoritative |
HW_Acc_bd.tcl |
the same block design exported as Tcl by write_bd_tcl. Not used by the flow, kept so that block-design changes are reviewable as a text diff. Re-export it whenever the block design changes |
reports_post_route.tcl |
post-route report hook: timing, utilization and the DSP count that distinguishes a FAST build from a Harris one. Also runs standalone against an archived routed checkpoint |
To rebuild:
vivado -mode batch -source vivado/create_project.tclTwo environment prerequisites, neither of which is a file in this repository. The project
was written by Vivado 2025.2.1 and uses the Vivado Synthesis 2025 and
Vivado Implementation 2025 flows, and HW_Acc_bd.tcl refuses to run on anything whose
version string does not contain 2025.2. The board part xilinx.com:kr260_som:part0:1.1
requires the Kria board files to be installed.
Nothing else is needed. The constraint set is empty, since the design is entirely PS-clocked with no PL pins of its own, and every IP in the block design comes from the standard Vivado catalog, so no external IP repository has to be registered.
The project references HW_Acc.bd in place rather than copying it, so Vivado treats this
folder as its working directory for the block design. After a run you will find:
| Path | What |
|---|---|
vivado/HW_Acc.bd |
rewritten with the elaborated state, roughly 146 KB to 205 KB |
vivado/HW_Acc.bda |
block-design layout file, ignored by git |
vivado/ip/ |
regenerated module-reference IP definitions, ignored by git |
vivado/Vivado-HW-Acc.gen/ |
generated output products, ignored by git |
Only the first one is tracked, so the others stay invisible. Discard the rewritten block design unless you actually changed it, which keeps the committed file the small, readable one rather than a Vivado re-serialization:
git checkout -- vivado/HW_Acc.bdThis matters most after a failed run, which can leave the file half-written.
The block design instantiates orb_feature_top and axi_axcache_override as module
references, so Vivado re-infers their IP definitions from src/ when the block design is
generated. src/ stays the single source of truth and the project holds no copy of it.
reports_post_route.tcl runs automatically at the end of route_design. To run it
standalone against an already routed checkpoint, pass the configuration and the checkpoint:
vivado -mode batch -source vivado/reports_post_route.tcl -tclargs HARRIS /path/to/HW_Acc_wrapper_routed_HARRIS.dcpCheckpoints live outside this repository, so the script has no default location for them.
Either give the .dcp path as the second argument, as above, or set ARCHIVE_ROOT in the
environment and the script derives $ARCHIVE_ROOT/$CFG/HW_Acc_wrapper_routed_$CFG.dcp.
Verify the configuration afterwards from the DSP count in the hierarchical utilization
report: 2 for a FAST build, 60 for Harris.
xmutil loadapp reads one firmware directory, /lib/firmware/xilinx/HW_Acc/, and expects
three files in it:
| File | Origin |
|---|---|
shell.json |
deploy/shell.json, tracked here |
HW_Acc.dtbo |
compiled from deploy/HW_Acc.dts, tracked here |
HW_Acc_wrapper.bit.bin |
produced from the Vivado bitstream, see below |
The bitstream itself is a build product and is not in this repository, but the step that
converts it is. Copy HW_Acc_wrapper.bit out of the implementation run into deploy/,
then run bootgen against the tracked image description HW_Acc_wrapper.bif:
bootgen -image HW_Acc_wrapper.bif -arch zynqmp -process_bitstream bin -w -o HW_Acc_wrapper.bit.binThe output name matters: it is what the firmware-name property in HW_Acc.dts looks for.
shell.json is the app descriptor. XRT_FLAT with one slot tells xmutil this is a flat
(non-DFX) design occupying the whole PL.
HW_Acc.dts is the device-tree overlay. It loads the bitstream named by its firmware-name
property, sets the PL clock to 125 MHz, and exposes the memory-mapped register files to
userspace as generic-uio nodes. It is written for the dual-accelerator build, and because
the second instance is simply appended, the same overlay also serves a single-instance
bitstream. The unused instance-1 nodes just sit idle.
dtc -@ -O dtb -o HW_Acc.dtbo HW_Acc.dts-@ keeps the symbol table the overlay needs to resolve &fpga_full and &amba.
Expected UIO probe order, assuming the base platform occupies uio0 through uio3:
| Node | Address | Purpose |
|---|---|---|
/dev/uio4 |
0xA000_0000 |
instance 0, AXI DMA registers |
/dev/uio5 |
0xA001_0000 |
instance 0, orb_feature_top registers |
/dev/uio6, /dev/uio7 |
SPI 89, 90 | instance 0 DMA interrupts, registered only so they stay masked |
/dev/uio8 |
0xB000_0000 |
instance 1, AXI DMA registers |
/dev/uio9 |
0xB001_0000 |
instance 1, orb_feature_top registers |
/dev/uio10, /dev/uio11 |
SPI 91, 92 | instance 1 DMA interrupts, masked |
Verify the numbering on the board before relying on it:
for d in /sys/class/uio/uio*; do echo "$d -> $(cat $d/name)"; doneThe driver polls DMASR and never opens the interrupt nodes. They exist only so that
uio_pdrv_genirq registers and masks the wired PL-to-PS lines, because an unhandled
level-high SPI would otherwise storm.
The pixel and keypoint buffers are not device-tree nodes. They are created by loading the
out-of-tree u-dma-buf module, one input and one output buffer per instance:
sudo insmod u-dma-buf.ko udmabuf0=1048576 udmabuf1=1048576 udmabuf2=1048576 udmabuf3=1048576| ID | Change |
|---|---|
0xC0DE0003 |
keypoint packing v3 (32-bit response in word 1, 24-bit angle plus flags in word 2), G_SCORE_TYPE generic, THRESH[16] readback |
0xC0DE0002 |
STALLCNT register at 0x2C, corner-FIFO backpressure and the UltraRAM split |