A compact, fully distributed algorithm that extracts edges from RGB images through four stages:
-
L0 — pixel harmonic contrast
Each pixel is compared to its eight neighbors in a learned luminance/chroma metric. Each direction's contrast is divided by its own energy plus a learned spatial pool of local contrast (contrast gain control). The result is a per-pixel harmonic field. -
L1 — oriented pooling
L0 harmonics are summed over small, overlapping patches on a sparse cell grid. Each cell gets eight orientation-tuned responses and a dominant orientationθ. -
Seed — facilitation and suppression
Each orientation response passes through Naka–Rushton gain control. Support from neighbouring cells along the contour (an association field with a learned curvature tolerance) scales the response and bridges gaps that have support on both sides. A divisive readout then divides each orientation response by the same orientation's response in the neighbouring cells, so a cell is coded relative to its same-orientation context. Parallel contours on both sides of a cell (flank suppression) divide it further, which damps texture and gratings but leaves a lone edge, or an edge beside a single parallel one, untouched. The result is per-cell contour densityρ. -
Render — ridge back-projection
Cellρis splatted back to full resolution with learned 1D kernels aligned to localθ, producing a soft boundary map. Non-max suppression yields the final edge map.
Paper: HaCI: Harmonic Contour Integration for Edge Detection, DOI 10.5281/zenodo.23403643.
For collaboration contact me on: [email protected]
Left: input image. Right: HaCI edge map from the bundled pretrained model.
Scores for pretrained/final.pt on the BRIND test set (200 images; the model was trained on the 300-image BRIND train split). Evaluation uses the Berkeley boundary benchmark: 99 thresholds and maxDist 0.0075 (≈4.3 px on 481×321 images). Predictions are thinned before one-to-one matching; see Test.
| Output | ODS | Precision | Recall | OIS | AP |
|---|---|---|---|---|---|
NMS-thinned map (s_eval) |
0.7179 | 0.6791 | 0.7614 | 0.7402 | 0.7651 |
Raw soft map (c_eval) |
0.7158 | 0.6842 | 0.7505 | 0.7373 | 0.6723 |
Precision and recall are taken at the ODS threshold (0.71 for both maps).
Speed and memory, measured on MPS (Apple M4) with baselines/speed.py.
| Resolution | Time | Host memory | GPU memory |
|---|---|---|---|
| 481×321 | 36 ms | 124 MB | 1.1 GB |
| 1280×720 | 201 ms | 171 MB | 1.1 GB |
| 1920×1080 | 459 ms | 233 MB | 2.2 GB |
HaCI/
├── pyproject.toml
├── requirements.txt
├── params.py # all hyperparameters
├── train.py # HaCIE2E training
├── test.py # ODS, OIS, AP evaluation
├── infer.py # single-image inference + diagnostics
├── scripts/ # biped.sh, brind.sh, nyud.sh — fetch and lay out datasets
├── haci/
│ ├── L0.py # pixel-level contrast + gain control
│ ├── L1.py # cell-level z₂ moments (E, C, θ)
│ ├── seed.py # η_z NR + association field + same-orientation surround + flank suppression → cell ρ for splat
│ ├── renderer.py # learned ridge projection
│ ├── splat_metal.py # the renderer's splat as Metal kernels, used with --device mps
│ ├── boundary_bench.py # Berkeley boundary benchmark (ODS, OIS, AP)
│ ├── cli.py # `uv run train` / `test` / `infer` entry points
│ └── diagnostics_viz.py # visualisation utilities
├── data/ # BRIND, the default dataset (scripts/brind.sh)
│ ├── train/imgs, train/gt # 300 training pairs
│ └── test/imgs, test/gt # 200 evaluation pairs
├── BIPED/, NYUDv2/ # other datasets, same {train,test}/{imgs,gt} layout (scripts/)
├── pretrained/
│ └── final.pt # bundled weights — infer / test without training
└── output/
├── checkpoints/ # final.pt, intermediate.pt
└── test/{c_eval,s_eval}/ # results.json, eval_bdry*.txt, preds/
Python 3.12+.
Install everything from pyproject.toml / requirements.txt (includes CPU torch from PyPI).
uv:
pip:
pip install -r requirements.txtCheck for a Nvidia GPU
For CUDA, note the driver version in the output and pick the matching PyTorch index tag (e.g. CUDA 12.8 → cu128). See pytorch.org/get-started.
- Install non-torch dependencies (or full sync).
- Install CUDA
torchfrom the PyTorch wheel index — use--reinstallso it replaces the CPU build fromuv sync.
uv:
uv sync
uv pip install --reinstall torch --index-url https://download.pytorch.org/whl/cu128pip:
pip install torch --index-url https://download.pytorch.org/whl/cu128
pip install matplotlib>=3.10.8 numpy>=2.0.0 pillow>=12.1.0 pyyaml>=6.0.0 scipy>=1.17.1Replace cu128 with your tag. Do not run pip install -r requirements.txt after the CUDA wheel, as that will pin CPU torch.
Verify:
uv run python -c "import torch; print(torch.__version__, torch.cuda.is_available())"Expect True for the second value on the CUDA path. train, test and infer pick cuda when available, then mps on Apple silicon, then cpu (the choice is printed as device=...); pass --device cpu to force CPU.
On mps, the renderer's splat and its gradient run as Metal kernels (haci/splat_metal.py) and compute the same sums as the CPU path, so results agree up to float rounding.
uv run train, uv run test and uv run infer run train.py, test.py and infer.py from the repo root. Without uv, run the scripts directly (python test.py ...) with the same flags.
infer takes the path to the image with -i / --image:
uv run infer -i assets/base.png
uv run infer -i ~/Pictures/photo.jpgOutputs go to --output_dir (default: output/results/). Add -d / --diagnostics for pinwheel, ρ maps, and overlay PNGs; add -v / --verbose to print learned parameters.
A pretrained checkpoint is included at pretrained/final.pt (learned L0 metric and gain control, seed, renderer). It's the default --model for infer and test, so you can run them without training your own weights. test and train default to BRIND in data/, which scripts/brind.sh creates:
# inference
uv run infer -i assets/base.png
# evaluation on the BRIND test set
sh scripts/brind.sh
uv run testTraining still writes new checkpoints under output/checkpoints/; pass --model to point at those instead.
Trains on BRIND in data/train by default:
Main flags: --train_imgs, --train_gt, --gt_format (png / mat; auto-detected from the GT folder if omitted), --epochs (default 50), --lr (default 5e-2), --batch_size, -n (cap the number of images), --device, --output_dir, --checkpoints_dir, --cache_dir, --num_workers (default 0: cached items load in milliseconds, and starting worker processes each epoch costs more than it saves), --grad_clip, --gt_min_agreement, --resume. --debug-seed runs one batch and prints the seed's parameters and gradients.
To continue an interrupted run, add --resume: it loads intermediate.pt from --checkpoints_dir (or the path you give it) and picks up at the next epoch.
BIPED has 250 outdoor 1280×720 images with expert edge annotations: 200 train, 50 test. scripts/biped.sh writes (BIPED/ is in .gitignore):
BIPED/train/imgs/ # 200 RGB images (.jpg)
BIPED/train/gt/ # 200 edge maps (.png, same stem)
BIPED/test/imgs/ # 50 RGB images
BIPED/test/gt/ # 50 edge maps
--kaggle downloads xavysp/biped with the Kaggle CLI, run through uvx. It needs a Kaggle API token in ~/.kaggle/kaggle.json, or KAGGLE_USERNAME and KAGGLE_KEY. If you downloaded BIPED yourself, pass the folder with --src-dir; when it contains both BIPED and BIPEDv2, v2 is used.
sh scripts/biped.sh --kaggle
# or: sh scripts/biped.sh --src-dir /path/to/downloaded/BIPED
# optional: --data-root DIR (default: BIPED)Train on the BIPED train split (writes checkpoints under output/checkpoints unless overridden):
uv run train \
--train_imgs BIPED/train/imgs \
--train_gt BIPED/train/gt \
--cache_dir cache/biped_trainUse a dedicated --cache_dir so BIPED caches do not mix with other experiments. Lower --batch_size if you hit GPU memory limits.
Test on the BIPED test split (ODS / OIS / AP; pairs images to GT by filename stem):
uv run test \
--dataset BIPED \
--images BIPED/test/imgs \
--test_gt BIPED/test/gt \
--output_dir output/test_bipedQuick smoke test: add -n 10.
BRIND is BSDS500 re-annotated at the edge level by RINDNet for four discontinuity types: reflectance, illumination, normal and depth. The published BRIND set merges them into one edge map per image, 300 train / 200 test. The per-type maps are only available from RINDNet.
BRIND is the default dataset. scripts/brind.sh clones it into data/, where train and test look when no paths are given:
data/train/imgs/ # 300 RGB images (.jpg)
data/train/gt/ # 300 edge maps (.png, same stem)
data/test/imgs/ # 200 RGB images
data/test/gt/ # 200 edge maps
sh scripts/brind.sh
# or, from an existing clone: sh scripts/brind.sh --src-dir /path/to/BRIND
# optional: --data-root DIR (default: data)Train and test on BRIND:
Clone the mirror at the repo root (e.g. next to this project): BIDS/BSDS500.
git clone https://github.com/BIDS/BSDS500.gitPaths below assume the usual layout inside the clone: BSDS500/BSDS500/data/images/{train,test} and BSDS500/BSDS500/data/groundTruth/{train,test}.
Train on the BSDS500 train split (MAT ground truth):
uv run train \
--train_imgs BSDS500/BSDS500/data/images/train \
--train_gt BSDS500/BSDS500/data/groundTruth/train \
--gt_format mat \
--cache_dir cache/bsds_trainUse a dedicated --cache_dir so BSDS caches do not mix with BIPED or other runs. Lower --batch_size if you run out of memory.
Test on the BSDS500 test split (MAT ground truth):
uv run test \
--dataset BSDS500 \
--images BSDS500/BSDS500/data/images/test \
--test_gt BSDS500/BSDS500/data/groundTruth/test \
--gt_format mat \
--output_dir output/test_bsds500NYUD v2 is an RGB-D dataset. Edge detection uses the ground truth that Gupta et al. released for its 1449 labelled images: 795 train+val and 654 test. scripts/nyud.sh downloads their release (eccv14-data.tgz, about 900 MB) and writes:
NYUDv2/train/imgs/ # 795 RGB images (img_XXXX.png)
NYUDv2/train/gt/ # ground truth, one BSDS-format groundTruth .mat per image
NYUDv2/test/imgs/ # 654 RGB images
NYUDv2/test/gt/
sh scripts/nyud.sh
# or, from an extracted copy: sh scripts/nyud.sh --src-dir /path/to/eccv14-data
# optional: --data-root DIR (default: NYUDv2)Train on the NYUD train split (.mat ground truth is detected automatically):
uv run train \
--train_imgs NYUDv2/train/imgs \
--train_gt NYUDv2/train/gt \
--cache_dir cache/nyudv2_trainUse a dedicated --cache_dir so NYUD caches do not mix with other experiments. Lower --batch_size if you hit GPU memory limits.
Test on the NYUD test split. NYUD results are reported with a matching distance of 0.011, looser than BSDS's 0.0075, because its ground truth is less precisely localised:
uv run test \
--dataset NYUDv2 \
--images NYUDv2/test/imgs \
--test_gt NYUDv2/test/gt \
--tol 0.011 \
--output_dir output/test_nyudv2Quick smoke test: add -n 20.
Walks a single image directory, pairs each image with ground truth by matching filename stems, and scores both the raw map (c_eval) and the NMS-thinned map (s_eval) with the Berkeley boundary benchmark. Without path flags it evaluates BRIND in data/test:
uv run test --model output/checkpoints/final.ptThe protocol follows the Berkeley benchmark (haci/boundary_bench.py). At each of --nthresh thresholds, the saved 8-bit prediction is binarised and thinned (bwmorph(·, 'thin', inf)). It is then matched one-to-one against each annotator within tol × image diagonal pixels. ODS interpolates between thresholds, OIS uses each image's best threshold, and AP is the area under the PR curve resampled at 0.01 recall. Each annotator in a .mat file is matched separately; BSDS500 has about five per image. A PNG ground-truth map counts as a single annotator. Scores from earlier versions of test.py, which used a dilation match, are not comparable.
| Flag | Default | Role |
|---|---|---|
--dataset |
BRIND |
Dataset name shown in the report and results.json |
--images |
data/test/imgs |
RGB test images (.jpg/.png) |
--test_gt |
data/test/gt |
Ground truth maps (.png/.jpg/.mat) |
--gt_format |
auto | png or mat (BSDS) |
--model |
pretrained/final.pt |
Checkpoint |
--output_dir |
output/test |
Output directory |
-n |
all | Cap number of images |
--device |
CUDA, else MPS, else CPU | cpu, cuda, or mps |
--diagnostics |
off | Save ρ and geometry maps per image under output_dir/diagnostics/ |
--tol |
0.0075 |
Matching distance as a fraction of the image diagonal (NYUD convention: 0.011) |
--nthresh |
99 |
Number of thresholds |
--workers |
min(8, cores) |
Benchmark processes (inference stays on --device) |
Each output_dir/{c_eval,s_eval}/ gets results.json (summary, PR curve, per-image counts) and eval_bdry.txt, eval_bdry_thr.txt, eval_bdry_img.txt in the Berkeley column layout, so standard PR-curve plotting scripts can read them.
Single-image edge detection on the image at the -i path.
# assets/base.png → output/results/
uv run infer -i assets/base.png
# your own image and checkpoint, with diagnostics and learned parameters
uv run infer -i /path/to/images/photo.png \
--model output/checkpoints/final.pt -d -v| Flag | Default | Role |
|---|---|---|
-i, --image |
(required) | Path to the input image |
--model |
pretrained/final.pt |
Checkpoint |
--output_dir |
output/results |
Where edge PNGs (and diagnostics) are written |
-d, --diagnostics |
off | Save pinwheel, ρ maps, geometry, overlay, etc. |
-t, --threshold |
0.5 |
Binarization threshold on the soft boundary map |
--ridge-nms |
on | Directional NMS along renderer θ; use --no-ridge-nms for raw map |
-v, --verbose |
off | Print learned parameters and timings |
--gt_dir |
none | Ground-truth folder (same stem as the image) to colour diagnostics |
--gt_format |
auto | png or mat when --gt_dir is set |
--shape_theta_bins |
12 |
Orientation bins in the render_theta_bins diagnostic |
--device |
CUDA, else MPS, else CPU | cpu, cuda, or mps |

