A deep learning framework for individual tree detection in forest point clouds using multi-layered forest structure.
TLNet slices a point cloud into horizontal slabs along the height axis, encodes each slab with a sparse convolutional backbone, aggregates the voxel features onto a shared 2D grid, fuses the layers with bidirectional layer-wise attention, and regresses stem positions as a CenterNet-style heat-map plus sub-cell offsets.
Method and results are described in the paper:
Yiliu Tan, Xin Yang, Jingyi Zhang, Xin Xu, Yunjian Cao, Di Wang, Maiko Shigeno. TLNet: A deep learning framework for tree detection in forest point clouds using multi-layered forest structure. ISPRS Journal of Photogrammetry and Remote Sensing, Volume 234, 2026, pp. 227–241. doi:10.1016/j.isprsjprs.2026.02.002
This repository contains TLNet itself — the model, the loss, the training loop and the evaluation — plus a description of the data it consumes and produces.
It does not prescribe a dataset. You supply a Dataset returning the documented
dictionary; TLNet returns tree coordinates and scores. Preprocessing and any
downstream use of the detections are left to you.
your data ──► your Dataset ──► ┌────────┐ ──► (xy, scores) ──► your analysis
(any format) (input contract) │ TLNet │ (output contract)
└────────┘
TLNet/
├── configs/tlnet.yaml All hyper-parameters live here
├── examples/
│ ├── example_dataset.py Reference Dataset implementation
│ └── sample_grid_gt_data.csv Ground-truth file format example
├── tlnet/
│ ├── config.py YAML loading + command-line overrides
│ ├── data/__init__.py Input contract, slab slicing, collate
│ ├── models/ Model, attention, head, decoding, backbone
│ ├── losses/centernet.py Heat-map focal loss + offset L1
│ ├── engine/ Training loop and evaluation
│ └── utils/misc.py Seeding, dataset loading, matching metrics
├── tools/{train,evaluate}.py Entry points
├── tests/ Regression tests (no dataset required)
└── docs/SETUP.md Environment setup
TLNet needs three extensions that must match your PyTorch and CUDA versions:
spconv, torch-scatter and torch-cluster. docs/SETUP.md
gives a verified recipe and covers the common installation pitfalls.
pip install torch --index-url https://download.pytorch.org/whl/cu117
pip install spconv-cu117
# Install the sparse/graph operators from prebuilt wheels, never from source
pip install torch-scatter==2.1.1 torch-cluster==1.6.1 --only-binary=:all: \
-f https://data.pyg.org/whl/torch-1.13.1+cu117.html
pip install -r requirements.txtVerified combination: Python 3.8, PyTorch 1.13.1+cu117, spconv-cu117, torch-scatter 2.1.1, torch-cluster 1.6.1.
The scripts under tools/ add the repository root to sys.path, so
pip install -e . is optional.
Write a torch.utils.data.Dataset whose __getitem__ returns:
| Key | Type / shape | Meaning |
|---|---|---|
points |
FloatTensor [N, 3] |
the plot's point cloud (x, y, z). Mutually exclusive with slabs |
slabs |
list of dict | pre-sliced layers, bottom to top, each {"points": [N_i, 3]}. Only if you slice the cloud yourself |
grid_coords |
FloatTensor [PQ, 2] |
centre (x, y) of every grid cell; must form a regular lattice |
grid_size |
float | cell edge length |
tree_loc_full |
FloatTensor [PQ, 2] |
true stem position for each cell, nan where the cell has no tree |
tree_grid_idx |
LongTensor [M] |
indices into grid_coords of the cells that contain a tree |
tree_locs |
FloatTensor [M, 2] |
stem positions of those M trees, used by the detection metrics |
name |
str | sample identifier, used in logs |
Supply either points or slabs. With points, TLNet slices the cloud using
data.num_slabs and data.skip_bottom_slabs.
A working implementation is in examples/example_dataset.py.
One row per grid cell, holding the stem position of the tree inside that cell (if any). See examples/sample_grid_gt_data.csv, which opens directly in Excel:
Grid_X,Grid_Y,Tree_X,Tree_Y,treeID
-120.8,-79.3,,,
-118.8,-77.3,-118.24,-77.05,1
-116.8,-77.3,,,
-114.8,-75.3,-114.31,-75.62,2| Column | Meaning | Required |
|---|---|---|
Grid_X, Grid_Y |
grid-cell centre | ✅ |
Tree_X, Tree_Y |
stem position inside the cell; empty if no tree | ✅ |
treeID |
tree identifier | optional |
Grid_X/Grid_Ymust be evenly spaced; the cell size and grid extent are inferred from their unique values.- At most one tree per cell.
All coordinates must share one unit, and every length in the configuration
(model.voxel_size, model.radius, eval.match_radius) uses that same unit.
data.units_per_meter only says how to convert distances for reporting: set it
to 10 for decimetres, 1 for metres, 100 for centimetres.
The shipped configuration assumes decimetres. If your data is in metres, either
multiply the coordinates by 10, or set units_per_meter: 1 and divide
voxel_size, radius and match_radius by 10.
tlnet.engine.predict(model, batch, device, cfg_eval) returns
(pred_xy, scores, outputs):
| Value | Type / shape | Meaning |
|---|---|---|
pred_xy |
FloatTensor [K, 2] |
absolute stem coordinates, in the same unit as the input |
scores |
FloatTensor [K] |
heat-map score of each detection, in (0, 1) |
outputs |
dict | the raw detection-head output, see below |
K varies per plot: detections are the cells that survive NMS and exceed
eval.score_thresh. Positions already include the regressed sub-cell offset,
so they are not snapped to cell centres.
If you want the dense maps instead of the decoded list, outputs contains:
| Key | Type / shape | Meaning |
|---|---|---|
hm |
FloatTensor [1, 1, H, W] |
heat-map in (0, 1), before NMS |
offset |
FloatTensor [1, 2, H, W] |
(dx, dy) per cell, in units of one grid cell |
H, W |
int | grid dimensions |
grid_size |
float | cell edge length |
grid_origin |
FloatTensor [2] |
absolute coordinate of cell (0, 0) |
grid_coords |
FloatTensor [PQ, 2] |
the input grid, passed through |
The absolute position of cell (ix, iy) is
grid_origin + (ix, iy) * grid_size + offset[:, iy, ix] * grid_size.
NMS and thresholding happen only at decoding time and are detached, so the loss always sees the raw heat-map.
All hyper-parameters live in configs/tlnet.yaml, which
documents each key inline. Point data.dataset at your Dataset class, then:
# Train
python tools/train.py --config configs/tlnet.yaml
# Override any key (dotted path)
python tools/train.py --config configs/tlnet.yaml \
--opt train.epochs=50 model.radius=10.0
# Evaluate
python tools/evaluate.py --config configs/tlnet.yaml \
--checkpoint runs/baseline/checkpoints/best.pth
# Evaluate without NMS
python tools/evaluate.py --config configs/tlnet.yaml --checkpoint X \
--opt eval.nms.enabled=Falsedata.dataset is an import path "module:ClassName". The class is constructed
twice, with split="train" and split="test", receiving data.dataset_args as
keyword arguments. If your Dataset has a different signature, build the two
objects yourself and call tlnet.data.build_loaders directly.
Using TLNet as a library, without the provided training loop:
from tlnet.data import collate_sample
from tlnet.engine import predict
from tlnet.models import build_model
batch = collate_sample([my_dataset[0]], num_slabs=32, skip_bottom_slabs=2)
pred_xy, scores, outputs = predict(model, batch, device, cfg.eval)Each run writes the effective configuration to <ckpt_dir>/config_used.yaml.
Checkpoints hold the full training state; best.pth is selected by
train.best_metric. Evaluation writes eval_per_sample.csv and
eval_summary.csv (precision, recall, F1, MAE in metres) to output.eval_dir.
pytest tests/Covers grid discretisation, loss target rendering, decoding and NMS, attention trainability and RNG isolation, the architecture switches, and the input contract. Synthetic tensors only, so no dataset is required.
@article{tan2026tlnet,
title = {TLNet: A deep learning framework for tree detection in forest point
clouds using multi-layered forest structure},
author = {Tan, Yiliu and Yang, Xin and Zhang, Jingyi and Xu, Xin and
Cao, Yunjian and Wang, Di and Shigeno, Maiko},
journal = {ISPRS Journal of Photogrammetry and Remote Sensing},
volume = {234},
pages = {227--241},
year = {2026},
issn = {0924-2716},
doi = {10.1016/j.isprsjprs.2026.02.002},
url = {https://www.sciencedirect.com/science/article/pii/S0924271626000535}
}