# Integrate Matcher

> Integrate a new image matching model into the image-matching-webui project. Invoke when user provides a GitHub repo URL of a feature matching method and asks to add/integrate it.

- Skill: `vincentqyw/integrate-matcher` (Agent Skill)
- Install (CLI): `npx skillmds@latest add vincentqyw/integrate-matcher`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vincentqyw/integrate-matcher/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: vincentqyw (https://skillmd.com/u/vincentqyw)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/vincentqyw/integrate-matcher

---


# Integrate Matcher into image-matching-webui

This skill automates the integration of a new local feature matching method into the `image-matching-webui` project. Given a GitHub repository URL, it follows the project's established patterns to add the matcher as a fully functional option in the WebUI.

## Prerequisites

- The target repository must be a local feature matching method (sparse or standalone)
- You must be working inside the `image-matching-webui` project root

## Step-by-Step Integration Guide

### Step 1: Analyze the Target Repository

1. **Clone the repo** to `/tmp/<repo-name>` for analysis (do NOT add as submodule yet)
2. **Identify the matcher type**:
   - **Dense/Standalone matcher**: Takes raw images as input, performs detect+describe+match internally (e.g., LoMa, RoMa, LoFTR). `required_inputs = ["image0", "image1"]`
   - **Sparse matcher**: Takes keypoints+descriptors as input, performs matching only (e.g., LightGlue, SuperGlue). `required_inputs` includes `keypoints0`, `descriptors0`, etc.
3. **Find the core model class** and its API:
   - How to initialize the model (constructor args, config options)
   - How to run inference (forward method signature)
   - What the model outputs (keypoints, matches, scores, etc.)
   - Model weight download URLs
4. **Check dependencies** in `pyproject.toml` or `requirements.txt`
5. **Identify model variants** (e.g., different sizes: B/L/G/R)
6. **Check for device/amp issues**:
   - Does the model use `torch.autocast` or mixed precision (`mp`, `amp`)?
   - Does it manage its own device placement (like LoMa's `loma.device`)?
   - On MPS/CPU, does it produce dtype mismatches?

### Step 2: Add Git Submodule

```bash
git submodule add <repo-url> imcui/third_party/<RepoName>
```

- Use the original repo name (PascalCase) as the submodule directory name
- If the repo has a fork in the project's org (e.g., `Vincentqyw/xxx` or `agipro/xxx`), prefer the fork

#### ⚠️ CRITICAL: Never modify third_party code directly

`imcui/third_party/` contains **pinned third-party submodules** — you are NOT the owner of this code. If a dependency needs a compatibility fix (e.g., API changes in PyTorch/kornia, import path changes):

1. **Fork** the original repo to the `agipro` GitHub account
2. **Apply the fix** in the fork and push
3. **Replace** the submodule URL in the main repo to point to the fork:
   ```bash
   git submodule deinit -f imcui/third_party/<RepoName>
   git rm -f imcui/third_party/<RepoName>
   rm -rf .git/modules/imcui/third_party/<RepoName>
   git submodule add https://github.com/agipro/<RepoName>.git imcui/third_party/<RepoName>
   ```
4. **Update `.gitmodules`** — the URL must point to the fork

Example: `EfficientLoFTR` was forked to `agipro/EfficientLoFTR` to fix a `kornia.utils.grid` → `kornia.utils` import change required by kornia 0.8+.

### Step 3: Create Matcher Implementation

Create `imcui/hloc/matchers/<matcher-name>.py` following these rules:

#### 3.1 Import Pattern

```python
import sys
from pathlib import Path

from .. import logger
from ..utils.base_model import BaseModel

# Add third_party to sys.path for import
<matcher>_path = Path(__file__).parent / "../../third_party/<RepoName>/src"
sys.path.append(str(<matcher>_path))

# Import the model class
from <module> import <ModelClass>
```

- If the third_party repo has `src/` as the package root, append `<path>/src`
- Some repos put the package directly at root level — adjust accordingly

#### 3.2 Class Definition

The class MUST:
- Inherit from `BaseModel`
- Be the **only** `BaseModel` subclass in the file (the `dynamic_load` function asserts this)
- Define `default_conf` dict with all configurable parameters
- Define `required_inputs` list

```python
class MatcherName(BaseModel):
    default_conf = {
        "name": "two_view_pipeline",
        "model_name": "<default_variant>",
        "max_keypoints": 2048,
        # ... other config
    }
    required_inputs = [
        "image0",
        "image1",
        # For sparse matchers, also include:
        # "keypoints0", "scores0", "descriptors0",
        # "keypoints1", "scores1", "descriptors1",
    ]
```

#### 3.3 `_init` Method

- Use `self.conf` (not the `conf` parameter) to access merged config
- Log model loading with `logger.info()`
- Handle model weight downloading:
  - If weights are on HuggingFace: use `self._download_model(repo_id=MODEL_REPO_ID, filename=...)`
  - If weights are auto-downloaded by the model (e.g., `torch.hub`), let it handle
  - If weights need manual download, document the URL
- **Platform compatibility**: On non-CUDA devices, disable mixed precision:
  ```python
  if not torch.cuda.is_available():
      # Disable mp/amp for CPU/MPS compatibility
  ```

#### 3.4 `_forward` Method

**For standalone matchers** (input: raw images):

The `data` dict contains preprocessed tensors:
- `data["image0"]`: shape `(1, C, H, W)`, float32, range [0, 1]
- `data["image1"]: shape `(1, C, H, W)`, float32, range [0, 1]

If the model expects PIL images or file paths, convert:
```python
img0 = data["image0"].cpu().numpy().squeeze() * 255
img0 = img0.transpose(1, 2, 0)  # CHW -> HWC
img0 = Image.fromarray(img0.astype("uint8"))
```

**For sparse matchers** (input: keypoints + descriptors):
```python
# Repackage data for the model's expected format
input = {
    "image0": {"image": data["image0"], "keypoints": data["keypoints0"], ...},
    "image1": {"image": data["image1"], "keypoints": data["keypoints1"], ...},
}
return self.net(input)
```

#### 3.5 Output Format

The `_forward` method MUST return a dict with these keys:

**Dense matchers that output matched keypoints only:**
```python
pred = {
    "keypoints0": kpts0,      # torch.Tensor, shape (N, 2), pixel coords in resized image
    "keypoints1": kpts1,      # torch.Tensor, shape (N, 2)
    "mconf": confidence,      # torch.Tensor, shape (N,), match confidence scores
}
```

**Dense matchers that can separate detected vs matched keypoints (PREFERRED):**
```python
pred = {
    "keypoints0": all_kpts0,     # All detected keypoints (for UI "Keypoints" display)
    "keypoints1": all_kpts1,     # All detected keypoints
    "mkeypoints0": matched_kpts0, # Matched keypoints (for UI match lines)
    "mkeypoints1": matched_kpts1, # Matched keypoints
    "mconf": confidence,          # Match confidence scores
}
```

**Sparse matchers** (LightGlue-style):
```python
# Return the model's raw output — match_dense.py handles the rest
return self.net(input)
```

Key output rules:
- `keypoints0/1`: ALL detected keypoints (shown in UI "Open for More: Keypoints")
- `mkeypoints0/1`: Only MATCHED keypoints (shown as match lines in UI)
- If `mkeypoints0/1` is missing, `match_dense.py` falls back to `keypoints0/1`
- Coordinates must be in **pixel space** of the resized image (not normalized [-1,1])
- `mconf` should be real confidence scores (not all-ones)

### Step 4: Add Matcher Configuration

#### 4.1 `imcui/hloc/configs/matchers.py`

Add a configuration entry for each model variant:

```python
"<matcher-name>": {
    "output": "matches-<matcher-name>",
    "model": {
        "name": "<matcher-module-name>",  # Must match the .py filename in matchers/
        "model_name": "<variant>",         # Passed as conf["model_name"]
        "max_keypoints": 2048,
        # ... other model-specific config
    },
    "preprocessing": {
        "grayscale": False,     # True for LoFTR-style; False for most modern matchers
        "force_resize": True,
        "resize_max": 1024,
        "width": 640,
        "height": 480,
        "dfactor": 8,          # Image dimensions must be divisible by this
    },
},
```

Key rules:
- `"name"` in `model` must match the Python filename (e.g., `"loma"` → `loma.py`)
- Each model variant gets its own top-level entry (e.g., `loma-b`, `loma-l`, `loma-g`)
- `preprocessing.grayscale`: Set `True` only for models that expect 1-channel input (e.g., LoFTR)
- `preprocessing.dfactor`: Ensure resized dimensions are divisible by this value

#### 4.2 `config/app.yaml` (local dev) AND `imcui/config/app.yaml` (package default)

BOTH files must be updated identically. Add an entry under `matcher_zoo`:

```yaml
<DisplayName>:
  matcher: <matcher-config-name>    # Must match key in matchers.py
  standalone: true                   # true = takes two images directly (no separate extractor needed)
  skip_ci: false                     # DEFAULT: false. Only set to true for heavy matchers (see rule below)
  info:
    name: <DisplayName>              # Display name in WebUI dropdown
    source: "Venue Year"             # e.g., "ECCV 2026", "ICCV 2023"
    paper: <paper-url>               # arXiv or published paper URL
    github: <github-url>             # Official GitHub repo
    display: true                    # Whether to show in WebUI
    efficiency: medium               # low (heavy), medium, high (fast)
```

Key rules:
- `standalone: true` means the matcher takes two raw images directly (no separate feature extractor needed)
- `standalone: false` means the matcher requires a feature extractor (e.g., SuperPoint+LightGlue)
- For feature+matcher combos, use `<extractor>+<matcher>` format (e.g., `superpoint+lightglue`)
- Set `enable: false` for very heavy models that most users won't use by default
- **CRITICAL**: Both `config/app.yaml` and `imcui/config/app.yaml` must be kept in sync

##### `skip_ci` rule

`skip_ci` defaults to `false` — you can **omit the field entirely** for most matchers. Only set `skip_ci: true` when the model is **too heavy to run in CI** (GitHub Actions CPU-only Ubuntu runner with 7GB RAM):

| Condition | `skip_ci` |
|-----------|-----------|
| Lightweight matcher (e.g., LightGlue, SuperGlue, XFeat, ALIKED-based) | `false` (or omit) |
| Heavy dense matcher prone to CI OOM/timeout (e.g., RoMa, DKM, GIM, Mast3R, LoMa-L/G/R, MINIMA large) | `true` |

Rule of thumb: if the model has variants like B/L/G/R, the "B" (base) variant usually passes CI, larger ones may not. When unsure, **omit `skip_ci`** (defaults to `false`) and let CI tell you — if it OOMs, set it to `true` in a follow-up commit.

### Step 5: Handle Platform Compatibility

Common issues and fixes:

#### 5.1 Mixed Precision (MP/AMP) Issues

On MPS/CPU, `torch.autocast` with float16/bfloat16 causes:
- `RuntimeError: Input type (c10::Half) and bias type (float) should be the same`
- `RuntimeError: Input type (MPSFloatType) and weight type (torch.FloatTensor) should be the same`

Fix pattern (before importing third-party code):
```python
# Patch module-level amp_dtype before import
import <module>.device as _device
if not torch.cuda.is_available():
    _device.amp_dtype = torch.float32

# Also patch submodule local bindings
import <module>.submod as _submod
if not torch.cuda.is_available():
    _submod.amp_dtype = torch.float32
```

After model construction:
```python
if not torch.cuda.is_available():
    cfg = dataclasses.replace(cfg, mp=False)
    for module in self.net.modules():
        if hasattr(module, "amp"):
            module.amp = False
```

#### 5.2 Device Mismatch Issues

If the model manages its own device (like LoMa's `loma.device`):
```python
# Override .to() to keep model on its expected device
def to(self, device=None, **kwargs):
    return super().to(<model_expected_device>, **kwargs)
```

#### 5.3 Inference Mode vs No Grad

If a model uses `@torch.inference_mode()` but you need to pass its outputs to another module that requires grad tracking:
```python
with torch.no_grad():
    output = model.detect_and_describe(...)
    output = output.clone()  # Detach from inference mode graph
```

### Step 6: Update README.md Algorithm Table

The README.md contains a "The tool currently supports..." table listing all algorithms with their support status.

#### 6.1 Table Format

```markdown
| Algorithm        | Supported | Conference/Journal | Year | GitHub Link |
|------------------|-----------|--------------------|------|-------------|
| LoMa             | ✅ | ECCV    | 2026 | [Link](https://github.com/davnords/LoMa) |
| RIPE             | ✅ | ICCV    | 2025 | [Link](https://github.com/fraunhoferhhi/RIPE) |
```

Rows are sorted by **year descending**, then **alphabetically by algorithm name** within the same year.

#### 6.2 Rules

| Scenario | Action |
|----------|--------|
| Algorithm exists with `❌` | Change to `✅` |
| Algorithm not in table | Add new row, maintaining sort order |
| Algorithm already has `✅` | Skip (no change needed) |

#### 6.3 Example

After integrating "DaD" published at ARXIV 2025:
```markdown
| DaD            | ✅ | ARXIV   | 2025 | [Link](https://github.com/Parskatt/dad) |
```
Insert between RIPE (ICCV 2025) and MINIMA (ARXIV 2024).

### Step 7: Pre-Commit Check (MANDATORY)

**Before committing, pre-commit MUST pass.** This is non-negotiable — commits that fail pre-commit will be rejected at the PR stage.

```bash
# Run ALL pre-commit hooks on all files
pre-commit run -a

# Or run specific hooks if you only changed certain files
pre-commit run ruff --all-files       # Python linting
pre-commit run mypy --all-files       # Type checking (if new Python code)
```

Common issues and fixes:

| Hook | Common failure | Fix |
|------|---------------|-----|
| `ruff` | unused imports, line too long | Remove unused imports, wrap long lines |
| `ruff-format` | inconsistent indentation | Let ruff format: `ruff format <file>` |
| `mypy` | missing type annotations | Add type hints to new functions |
| `trailing-whitespace` | blank lines with spaces | Strip trailing whitespace |
| `check-yaml` | invalid YAML syntax | Fix indentation/quoting in app.yaml |
| `end-of-file-fixer` | missing newline at EOF | Add trailing newline |

If `pre-commit run -a` fails, **fix all errors** before proceeding to commit. Do NOT skip hooks with `--no-verify`.

### Step 8: Verification Checklist

After integration and pre-commit pass, verify:

1. **Import test**: `python -c "from imcui.hloc.matchers import <name>"`
2. **Model loading**: The model loads without errors on CPU/MPS/CUDA
3. **Inference test**: Run matching on a test image pair
4. **WebUI test**: `python app.py` → select the matcher → run matching
5. **Keypoints display**: UI "Open for More: Keypoints" shows detected keypoints
6. **Match lines display**: UI shows correct match lines between images
7. **Both config files**: `config/app.yaml` and `imcui/config/app.yaml` are identical for the new matcher
8. **Pre-commit passes**: `pre-commit run -a` exits with zero

### Step 9: Commit and Create PR

Once pre-commit passes and all verification checks are green:

```bash
# Stage the changes
git add .gitmodules imcui/third_party/<RepoName>
git add imcui/hloc/matchers/<name>.py
git add imcui/hloc/configs/matchers.py
git add imcui/config/app.yaml
git add config/app.yaml
git add README.md

# Commit with a descriptive message following convention
git commit -m "feat: integrate <MatcherName>

Add <MatcherName> matcher from <venue> <year>.

- Add submodule: imcui/third_party/<RepoName>
- Add matcher implementation: imcui/hloc/matchers/<name>.py
- Add matcher config entries
- Update app.yaml matcher zoo (both package and user configs)
- Update README.md algorithm table

Co-Authored-By: Claude <noreply@anthropic.com>"

# Push and create PR
git push origin main
gh pr create --title "feat: integrate <MatcherName>" \
  --body "Integrate <MatcherName> matcher from <paper-link>.

## Changes
- [x] Submodule added
- [x] Matcher implementation
- [x] Config entries in both app.yaml files
- [x] README.md updated
- [x] Pre-commit passes

## Tested on
- [ ] CPU
- [ ] CUDA
- [ ] MPS

🤖 Generated with [Claude Code](https://claude.com/claude-code)"
```

**Important notes:**
- Use `feat:` prefix for new matcher integrations (following conventional commits)
- Always include `Co-Authored-By: Claude <noreply@anthropic.com>` in commit messages
- End PR body with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`
- If CI fails after pushing, check the logs — if it's an OOM on your new matcher, set `skip_ci: true` in a follow-up commit

## File Change Summary

For each integration, these files are typically modified:

| File | Action | Description |
|------|--------|-------------|
| `.gitmodules` | Modify | Add submodule entry |
| `imcui/third_party/<RepoName>` | Add | Git submodule |
| `imcui/hloc/matchers/<name>.py` | Create | Matcher implementation |
| `imcui/hloc/configs/matchers.py` | Modify | Add matcher config entries |
| `config/app.yaml` | Modify | Add WebUI display config |
| `imcui/config/app.yaml` | Modify | Add WebUI display config (must match config/app.yaml) |
| `README.md` | Modify | Update supported algorithms table (add row or flip ❌→✅) |

Before committing all changes, run `pre-commit run -a` — all hooks must pass.

## Reference Implementations

| Pattern | File | Description |
|---------|------|-------------|
| Dense standalone | `imcui/hloc/matchers/roma.py` | RoMa — raw images → matched keypoints |
| Dense standalone + separate detected/matched kpts | `imcui/hloc/matchers/loma.py` | LoMa — separates all detected from matched keypoints |
| Sparse matcher | `imcui/hloc/matchers/lightglue.py` | LightGlue — keypoints+descriptors → matches |
| Detector-only + external descriptor | `imcui/hloc/extractors/raco.py` | RaCo — detects keypoints, delegates descriptor to ALIKED |

### RaCo pattern: detector that needs a descriptor extractor

Some models are **keypoint detectors only** — they output keypoints/scores but no descriptors. The RaCo integration demonstrates chaining RaCo detection with ALIKED description:

1. Create an **extractor** (not matcher) in `imcui/hloc/extractors/raco.py`
2. In `_forward`, first run RaCo detection → keypoints, then run ALIKED descriptor on those keypoints
3. Register in `configs/extractors.py` with `max_num_keypoints` / `nms_radius` parameters
4. Register a matcher config in `configs/matchers.py` with `features: "raco-aliked"` — a custom LightGlue+ checkpoint trained for RaCo+ALIKED features
5. In `app.yaml`, the entry uses `feature: raco` (the extractor) + `matcher: raco-lightglue` (the LightGlue variant)

### Fork priority for third-party fixes

When a submodule needs a compatibility fix:

| Repo owner | Action |
|------------|--------|
| `Vincentqyw/*` | Push fix directly to the Vincentqyw fork |
| `agipro/*` | Push fix directly to the agipro fork |
| Anyone else | Fork to `agipro`, apply fix, switch submodule URL |

Always verify push succeeded with `git log --oneline -1` in the submodule directory.

### Kornia compatibility (kornia >= 0.8)

Kornia 0.8.0 removed the `kornia.utils.grid` submodule. `create_meshgrid` moved to `kornia.utils` (0.8.0-0.8.2), then to `kornia.geometry` (0.8.3+). When fixing submodule imports, use this future-proof pattern:

```python
try:
    from kornia.geometry import create_meshgrid  # kornia >= 0.8.3
except ImportError:
    from kornia.utils import create_meshgrid  # kornia < 0.8.3
```

