Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/actions/uv-build/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: uv-build
description: Build the project with uv and run smoke tests
inputs:
python-version:
description: Python version to install
required: true
runs:
using: composite
steps:
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install Python
shell: bash
run: uv python install ${{ inputs.python-version }}
- name: Build
shell: bash
run: uv build
- name: Smoke test (wheel)
shell: bash
run: uv run --isolated --no-project --with dist/*.whl tests/test_smoke.py
- name: Smoke test (source distribution)
shell: bash
run: uv run --isolated --no-project --with dist/*.tar.gz tests/test_smoke.py
22 changes: 22 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Build

on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
run:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Build and smoke test
uses: ./.github/actions/uv-build
with:
python-version: ${{ matrix.python-version }}
41 changes: 41 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: PyPI Publish

on:
push:
tags:
# Publish on any tag starting with a `v`, e.g., v0.1.0
- v*
workflow_dispatch:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Build and smoke test
uses: ./.github/actions/uv-build
with:
python-version: ${{ matrix.python-version }}

publish:
runs-on: ubuntu-latest
needs: build
environment:
name: pypi
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Build and smoke test
uses: ./.github/actions/uv-build
with:
python-version: '3.12'
- name: Publish
run: uv publish
49 changes: 30 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,50 +17,61 @@
</p>

## Overview
HardMatch is an extremely difficult image matching benchmark featuring 1,000 hand annotated image pairs. The benchmark is released as part of the [LoMa](https://arxiv.org/abs/2604.04931) paper.
HardMatch is an extremely difficult image matching benchmark featuring 1,000 hand annotated image pairs. The benchmark is released as part of the [LoMa](https://arxiv.org/abs/2604.04931) paper (ECCV 2026, Oral). The best way to visualize the data is through our online [viewer](https://www.davnords.com/loma/hardmatch).

## Updates
- [August 5, 2026] PyPi package released and code made more accesible.
- [June 27, 2026] Initial dataset release following ECCV 2026 acceptance.

## Setup/Install
In your python environment (tested on Linux python 3.12):
```bash
uv add hardmatch
```
or
```bash
pip install hardmatch
```

## How to Use
```python
from hardmatch import HardMatchBenchmark
matcher = YourFancyMatcher()
result = HardMatchBenchmark().benchmark(matcher)
```
We additionally provide an example of the matching API through `demo.py` which uses [SuperPoint](https://github.com/rpautrat/SuperPoint) + [LightGlue](https://github.com/cvg/LightGlue). This defaults to evaluating on the 900 test pairs. There are also 100 validation pairs. To test this demo, just run:
`matcher` needs to satisfy the `BenchmarkMatcher` protocol ([`hardmatch.types.BenchmarkMatcher`](src/hardmatch/types.py)):

```python
class YourFancyMatcher:
offset = 0.0 # pixel offset subtracted from returned keypoints before scoring

def match(self, img_A_path: str, img_B_path: str) -> tuple[np.ndarray, np.ndarray]:
"""Return (kpts_A, kpts_B): corresponding (N, 2) keypoints in pixel
coordinates of the original image files, one row per match."""
...
return kpts_A, kpts_B
```

We additionally provide two example matchers through `demo.py`: [SuperPoint](https://github.com/rpautrat/SuperPoint) + [LightGlue](https://github.com/cvg/LightGlue) (`SPLG`), and [LoMa](https://github.com/davnords/loma) (`LoMa`). This defaults to evaluating on the 900 test pairs. There are also 100 validation pairs. To try the demo, first clone the repo, and then run:
```bash
uv run demo.py
uv sync --extra baselines
uv run demo.py --matcher loma
# Expected result: mAA_10px: 0.5061
```
Note, the results differs a tiny bit from the results in the [paper](https://arxiv.org/abs/2604.04931). This is because after submission we had change the dataset a tiny bit (around 10 pairs) for licensing issues.

## Download

Running the benchmark will automatically download the data (660MB). You can also manually download it [here](https://github.com/davnords/storage/releases/download/hardmatch/hardmatch.zip).

## Setup/Install
In your python environment (tested on Linux python 3.12), run:
```bash
uv pip install -e .
```
or
```bash
uv sync
```

## Checklist
- [ ] Provide expected results for SP+LG and LoMa.
- [ ] Remove Kornia dependency and split eval and dataset dependencies.
- [ ] Make into easy-to-use PyPi package.

## License
All our code is MIT license. The pairs are scraped from [WikiMedia Commons](https://commons.wikimedia.org/wiki/Main_Page). As such, each pair has its own license that you can find in the data. They are generally permissive.

## Acknowledgement
Our evaluation technique builds on [WxBS](https://arxiv.org/abs/1504.06603).

## BibTeX
If you find our models useful, please consider citing our paper!
If you find our dataset useful, please consider citing our paper!
```
@inproceedings{nordstrom2026loma,
title={LoMa: Local Feature Matching Revisited},
Expand Down
11 changes: 9 additions & 2 deletions demo.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
from baselines import SPLG
import argparse

from baselines import SPLG, LoMa
from hardmatch import HardMatchBenchmark

result = HardMatchBenchmark().benchmark(SPLG())
parser = argparse.ArgumentParser()
parser.add_argument("--matcher", choices=["splg", "loma"], default="splg")
args = parser.parse_args()

matcher = SPLG() if args.matcher == "splg" else LoMa()
result = HardMatchBenchmark().benchmark(matcher)
17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,25 @@ dependencies = [
"torchvision>=0.23.0",
"tqdm>=4.67.1",
"einops>=0.8.1",
"numpy",
"pillow",
"opencv-python>=4.12.0.88",
]

[project.urls]
Homepage = "https://github.com/davnords/HardMatch"
Repository = "https://github.com/davnords/HardMatch"
Paper = "https://arxiv.org/abs/2604.04931"

[project.optional-dependencies]
baselines = [
"lomatch>=1.0.0",
"kornia",
]
dev = [
"ruff",
"pyright",
]

[build-system]
requires = ["uv_build>=0.8.15,<0.9.0"]
Expand Down
45 changes: 41 additions & 4 deletions src/baselines/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
from dataclasses import replace
from typing import Literal

import torch

from .lightglue import LightGlue
from .superpoint import SuperPoint, load_image, rbd

device = "cuda" if torch.cuda.is_available() else "cpu"


class SPLG:
device = "cuda" if torch.cuda.is_available() else "cpu"
superpoint = SuperPoint(max_num_keypoints=4096).to(device).eval()
lightglue = LightGlue(features="superpoint", depth_confidence=-1, filter_threshold=0.0, width_confidence=-1).to(device).eval()
offset = 0.0 # Pixel offset
"""SuperPoint + LightGlue baseline matcher. See `hardmatch.types.BenchmarkMatcher`."""

offset = 0.0 # Pixel offset

def __init__(self, device: str = device):
self.device = device
self.superpoint = SuperPoint(max_num_keypoints=4096).to(device).eval()
self.lightglue = LightGlue(
features="superpoint", depth_confidence=-1, filter_threshold=0.0, width_confidence=-1
).to(device).eval()

def match(self, img_A_path: str, img_B_path: str):
# Reference: https://colab.research.google.com/github/cvg/LightGlue/blob/main/demo.ipynb#scrollTo=pjjHazDhC1tU
Expand All @@ -24,3 +37,27 @@ def match(self, img_A_path: str, img_B_path: str):
kpts0, kpts1, matches = feats0["keypoints"], feats1["keypoints"], matches01["matches"]
m_kpts0, m_kpts1 = kpts0[matches[..., 0]], kpts1[matches[..., 1]]
return m_kpts0.cpu().numpy(), m_kpts1.cpu().numpy()


class LoMa:
"""LoMa baseline matcher, from https://github.com/davnords/loma (`pip install lomatch`).
See `hardmatch.types.BenchmarkMatcher`.
"""

offset = 0.0 # Pixel offset

def __init__(
self,
size: Literal["B", "B128", "L", "G"] = "B",
device: str = device,
num_keypoints: int = 4096,
):
from loma import LoMa as _LoMa, LoMaB, LoMaB128, LoMaG, LoMaL

cfg = {"B": LoMaB, "B128": LoMaB128, "L": LoMaL, "G": LoMaG}[size]()
cfg = replace(cfg, num_keypoints=num_keypoints)
self.device = device
self.model = _LoMa(cfg).to(device).eval()

def match(self, img_A_path: str, img_B_path: str):
return self.model.match(str(img_A_path), str(img_B_path))
2 changes: 0 additions & 2 deletions src/baselines/lightglue.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
FLASH_AVAILABLE = True
else:
FLASH_AVAILABLE = False

torch.backends.cudnn.deterministic = True


Expand All @@ -27,7 +26,6 @@
else torch.cuda.amp.custom_fwd(cast_inputs=torch.float32)
)


@AMP_CUSTOM_FWD_F32
def normalize_keypoints(
kpts: torch.Tensor, size: Optional[torch.Tensor] = None
Expand Down
Empty file removed src/baselines/loma/__init__.py
Empty file.
1 change: 0 additions & 1 deletion src/baselines/loma/descriptor/__init__.py

This file was deleted.

Loading
Loading