oskarkuuse-pruna commited on
Commit
6979012
·
1 Parent(s): c7c030c

commit repo mirror

Browse files
Files changed (35) hide show
  1. .gitattributes +1 -0
  2. README.md +171 -0
  3. pyproject.toml +15 -0
  4. requirements.txt +11 -0
  5. src/__init__.py +0 -0
  6. src/superpoint_pruning/__init__.py +0 -0
  7. src/superpoint_pruning/cli.py +46 -0
  8. src/superpoint_pruning/distillation/__init__.py +0 -0
  9. src/superpoint_pruning/distillation/base_config.yaml +42 -0
  10. src/superpoint_pruning/distillation/data/train_base.txt +1575 -0
  11. src/superpoint_pruning/distillation/lightning_trainer.py +285 -0
  12. src/superpoint_pruning/distillation/losses.py +159 -0
  13. src/superpoint_pruning/distillation/setup.py +161 -0
  14. src/superpoint_pruning/distillation/utils.py +74 -0
  15. src/superpoint_pruning/evaluation/__init__.py +0 -0
  16. src/superpoint_pruning/evaluation/benchmark_sp_tensorrt.py +178 -0
  17. src/superpoint_pruning/evaluation/eval.py +293 -0
  18. src/superpoint_pruning/evaluation/metrics.py +106 -0
  19. src/superpoint_pruning/evaluation/onnx_helper.py +97 -0
  20. src/superpoint_pruning/evaluation/plot_keypoints.py +197 -0
  21. src/superpoint_pruning/export.py +111 -0
  22. src/superpoint_pruning/models/__init__.py +0 -0
  23. src/superpoint_pruning/models/superpoint.py +354 -0
  24. src/superpoint_pruning/paths.py +9 -0
  25. src/superpoint_pruning/pruning.py +100 -0
  26. src/superpoint_pruning/weights/16_16_24_32_64.ckpt +3 -0
  27. src/superpoint_pruning/weights/superpoint_v6_from_tf.pth +3 -0
  28. static/model_structure.png +3 -0
  29. static/pruned_indoor.png +3 -0
  30. static/pruned_indoor_separate.png +3 -0
  31. static/pruned_light_indoor.png +3 -0
  32. static/pruned_light_outdoor.png +3 -0
  33. static/pruned_noref_indoor.png +3 -0
  34. static/pruned_noref_outdoor.png +3 -0
  35. static/pruned_outdoor.png +3 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: superpoint-pruned
4
+ tags:
5
+ - SuperPoint
6
+ ---
7
+
8
+ # 1. SuperPoint Pruned
9
+ This repository focuses on optimizing the SuperPoint (SP) keypoint detection model for the TensorRT runtime on Jetson Orin Nano devices.
10
+ To achieve better performance, we use methods like structural pruning, distillation training for performance recovery, and general model improvements for serialized model exporting.
11
+ Despite the focus on the Jetson devices, the optimization methods are general enough to likely benefit other runtime backends as well and the ideas used can be applied to other similar network architectures.
12
+
13
+ As a base we use the open source PyTorch implementation of Superpoint and corresponding base weights by Rémi Pautrat and Paul-Edouard Sarlin, which can be found [in this repository](https://github.com/rpautrat/SuperPoint).
14
+ For distillation and evaluation, we use the [UZH FPV Dataset](https://fpv.ifi.uzh.ch/) [1].
15
+
16
+ Note that there are also other popular SuperPoint implementations available, especially versions paired with keypoint matching models such as LightGlue.
17
+ In this repository we won't be using them because of their restrictive license and instead our work is intended more as a proof of concept to demonstrate feasibility of optimizing SuperPoint-like models. One can in theory apply the methods covered here to alternative implementations by making necessary structural changes and running the distillation pipeline with desired configurations.
18
+
19
+
20
+ [1] J. Delmerico, T. Cieslewski, H. Rebecq, M. Faessler, and D. Scaramuzza,
21
+ “Are We Ready for Autonomous Drone Racing? The UZH-FPV Drone Racing Dataset,”
22
+ in *IEEE International Conference on Robotics and Automation (ICRA)*, 2019.
23
+
24
+ # 2. Optimization methods
25
+
26
+ The following provides a brief overview of the optimization methods used in the repository.
27
+
28
+ ## Pruning
29
+ Pruning is the main optimization method used and main motivation for this repository.
30
+ For context, the network backbone of the default SuperPoint model architecture consists of eight sequential convolutional layers as shown in the figure:
31
+
32
+ ![superpoint_structure](./static/model_structure.png)
33
+
34
+ The main observation is that after every two convolutional layers, the model has a pooling layer, which halves the width and height of the feature map.
35
+ This means that the biggest bottlenecks of the model are the first convolutional layers, since they have to process the image at full input resolution.
36
+ Hence the runtime can be improved by reducing the input/output channel sizes of desired convolutional layers, which our pruning interface allows to do with a simple class method addition.
37
+ Note that in case of channel pruning, as a default we keep the channels that correspond to kernels with the highest mean absolute weight values, which is a good heuristic to retain as much of original performance as possible and also offers a good initialization point for potential distillation training.
38
+
39
+ In the benchmarking section we outline two pruning configurations.
40
+ One prunes only the `backbone_0_1` layer input channels from 64 to 32 (which means also adjusting `backbone_0_0` output channels accordingly) with no additional training, since this is the most expensive layer in the model.
41
+ Another config we explore adjusts the first six layers of the model and because of that it requires distillation training to recover accuracy.
42
+ We provide the training checkpoint under `src/superpoint_pruning/weights/16_16_24_32_64.ckpt` and we use a combination of KL divergence and cross entropy loss for recovering the keypoint locations and a cosine similarity based loss for the descriptors.
43
+ Here the filename `16_16_24_32_64` refers to the new values of input channels for the corresponding layers starting from `backbone_0_1`, `backbone_1_0` and so on.
44
+
45
+ ## Hierarchical top-k keypoint selection
46
+ The original model extracts the final *k* keypoints by choosing the top scoring ones out of all possible pixel locations of the input image.
47
+ Since the *k* value is normally much smaller than the number of candidate, then this can be done hierarchically by first splitting the image into chunks and finding the top *k* keypoints in each chunk, after which the final results can be aggregated.
48
+ Using the same *k* and assuming no scoring ties, we are guaranteed the original result while getting a small speedup which is noticeable for constrained devices.
49
+
50
+ ## Skipping keypoint refinement
51
+
52
+ This is not a direct optimization, but rather a useful observation in case the model runtime is especially important.
53
+ The base model uses non-maximal supression to find top keypoints in each image region.
54
+ While doing this, the original implementation also does two additional refinement steps, which allows to extract other high scoring keypoints that were supressed by the first pass.
55
+ This usually results in more fine-grained keypoints in the final output, but in total the model does four extra passes over the full image resolution, which is very expensive.
56
+ Hence we include an option to skip this refinement, as the outputs still stay relatively sensible without it, but this depends on the use case.
57
+
58
+ # 3. Benchmark results
59
+ In the following we compare the quality and inference time of five different model variations:
60
+ - `original` - base SP with original weights
61
+ - `original-topk` - base SP with hierarchical top-k enabled. Each variant uses the original *k* for sub-chunks, which guarantees the original results. The resolution 640x480 uses 32 chunks and 1920x1080 uses 36 chunks.
62
+ - `pruned-light` - base SP where input channels of the `backbone_0_1` layer have been pruned from 64 to 32 with no additional training. This demonstrates the effect of the most expensive layer and how the kernel selection heuristic can retain performance. Previous top-k optimization is also enabled.
63
+ - `pruned` - uses the `16_16_24_32_64` pruning config that affects the first six layers of the model (as described in the methods section) and uses our distillation checkpoint weights, which is trained on 250 examples and recovers the performance. Exact training configuration is referenced in the usage section. Previous top-k optimization is also enabled.
64
+ - `pruned-noref` - previous `pruned` model with no NMS refinement steps to demonstrate the effects of it.
65
+
66
+ ## Quality evaluation
67
+
68
+ The previous models were evaluated using PyTorch using the native dataset image size of 640x480 as input and 1024 keypoints in each case. We differentiate two specific dataset traces:
69
+ - Indoor - 767 images from [UZH FPV Indoor forward facing trace #3](http://rpg.ifi.uzh.ch/datasets/uzh-fpv-newer-versions/v3/indoor_forward_3_snapdragon_with_gt.zip). This was the trace for training the `pruned` model and while we removed the direct images that were used for training, these results are still biased because of the sequential nature of the video frames.
70
+ - Outdoor - 500 images from the [UZH FPV Outdoor forward facing trace #1](http://rpg.ifi.uzh.ch/datasets/uzh-fpv-newer-versions/v3/outdoor_forward_1_snapdragon_with_gt.zip). Images from this trace were not used for performance recovery training of the pruned models and should serve as an example for the generalization capability of the distillation.
71
+
72
+ We report two scores for each combination in the table below as *average number of keypoints covered / mean descriptor difference L2 norm*.
73
+ The average number of keypoints covered score splits the original image into 8x8 chunks and checks if the target model predicted the same amount of keypoints in each chunk as the original. This isn't a perfect score but it also isn't as restrictive as exact match comparison and gives a general proxy score to assess, if the target model's keypoints have similar image coverage to the original (with 1024 being the best results, covering all original regions). In this case, a lower score doesn't mean that keypoints are definitely bad, but they might be more concentrated to a specific region than the original.
74
+ The L2 norm is calculated by comparing the descriptor vectors at shared keypoint locations and calculating the norm of their difference. This score serves just as a quantitative signal for relative performance - in an ideal case this could be combined with a matching model to measure if the target model descriptor vectors are still recognizable.
75
+
76
+ Note that generally, the best way to assess the pruned versions of SP is to test it in the intended downstream task and see if it still retains performance. We don't explore it here since we are not looking at a specific task currently.
77
+
78
+ | Trace | original | original-topk | pruned-light | pruned | pruned-noref |
79
+ |---|---:|---:|---:|---:|---:|
80
+ | Inside | 1024 / 0 | 1024 / 0 | 637 / 0,92 | 817 / 0,24 | 700 / - |
81
+ | Outside | 1024 / 0 | 1024 / 0 | 485 / 0,89 | 754 / 0,27 | 671 / - |
82
+
83
+ In the following you can also see a few visualized examples of the predicted keypoints. The used models from left to right are `pruned-light`, `pruned` and `pruned-noref` respectively. The first set uses `image_1_1200.png` from the Indoor trace and the second set uses `image_0_1300.png` from the outdoor trace.
84
+
85
+ All of the target model keypoints are compared to the original model predictions, hence the green points are keypoints which both models predicted exactly, while red and blue are points which one predicted, but the other did not.
86
+ Notice that while there aren't many exactly overlapping points, there are many points where the original and target model predictions lie very close. Additionally, while not all of the target model prediction match the original, then often the predicted points are still sensible keypoint locations.
87
+
88
+ | `pruned-light`|`pruned` |`pruned-noref` |
89
+ |---|---|---|
90
+ | ![](./static/pruned_light_indoor.png) | ![](./static/pruned_indoor.png) | ![](./static/pruned_noref_indoor.png) |
91
+
92
+ | `pruned-light`|`pruned` |`pruned-noref` |
93
+ |---|---|---|
94
+ | ![](./static/pruned_light_outdoor.png) | ![](./static/pruned_outdoor.png) | ![](./static/pruned_noref_outdoor.png) |
95
+
96
+ Alternatively, we can also compare the results with no overlay, e.g., here is the indoor image for the `pruned` model:
97
+
98
+ ![superpoint_structure](./static/pruned_indoor_separate.png)
99
+
100
+ ## Inference time evaluation
101
+ For this section, all of the previous model configurations were exported as ONNX models and then serialized as TensorRT models using the `trtexec` CLI utility (with TRT version 10.3.0) with the `--fp16` flag to enable fp16 precision, which offers additional speedup for no quality degradation.
102
+ Testing is done on a Jetson Orin Nano 8GB version and two times are reported: the first is the mean inference time across 1000 images from the previous indoor trace using our python benchmarking script and the second is the mean inference time reported by the TensorRT benchmarking utility (used as `trtexec --loadEngine=serialized_engine_file --useCudaGraph --useSpinWait`).
103
+ All times are reported as milliseconds.
104
+
105
+ | Input resolution | original | original-topk | pruned-light | pruned | pruned-noref |
106
+ |---|---:|---:|---:|---:|---:|
107
+ | 640 × 480 | 17,8 / 16,8 | 17,0 / 16,0 | 14,4 / 13,5 | 9,6 / 8,5 | 8,3 / 7,0 |
108
+ | 1920 × 1080 | 111,1 / 109,6 | 102,5 / 102,1 | 86,9 / 84,6 | 54,2 / 52,9 | 45,1 / 43,5 |
109
+
110
+ # 4. Project structure
111
+ The code is organized into separate modules under `src/superpoint_pruning/`:
112
+ - `export.py` - utility script for exporting a pruned SuperPoint model as ONNX.
113
+ - `models/superpoint.py` - base optimized SuperPoint PyTorch model.
114
+ - `distillation/` - PyTorch Lightning trainer script and utilities for distillation training. Includes `setup.py` script for downloading the base dataset that was used for training.
115
+ - `evaluation/` - quality evaluation scripts and TensorRT wrapper for inference timing measurements.
116
+ - `weights/` - base SuperPoint weights and precomputer distillation checkpoints.
117
+
118
+
119
+ # 5. Setup and usage
120
+ For installing the package with dependencies for running the basic distillation, evaluation, and export scripts, run:
121
+ ```
122
+ pip install -r requirements.txt
123
+ pip install -e .
124
+ ```
125
+ To install the base dataset images (UZH FPV Indoor forward facing trace #3) used for training to the default path `src/superpoint_pruning/distillation/data`, run:
126
+ ```
127
+ superpoint-pruning setup
128
+ ```
129
+ Alternatively, specify the desired trace download link with the `--download-url` flag, e.g., the one for the UZH FPV Outdoor forward facing trace #1, and the local dataset directory name with `--output-dir-name`.
130
+
131
+ ## Run distillation training
132
+ The base PyTorch Lightning training config that we used to produce the `pruned` model can be found under `src/superpoint_pruning/distillation/base_config.yaml`.
133
+ Since this includes the hyperparameters and the data setup used for our training, then this should allow to reproduce the trained checkpoint that is provided under `src/superpoint_pruning/weights/16_16_24_32_64.ckpt`. The training script itself is very basic but can serve as a starting point for further experiments.
134
+ To replicate the training, assuming that the main Indoor data trace is installed in the previous, run:
135
+ ```
136
+ superpoint-pruning setup --generate-gt
137
+ superpoint-pruning train
138
+ ```
139
+ The first command computes and presaves the target keypoint and descriptor feature maps and the other initiate the Lightning training script.
140
+
141
+ ## Run evaluation
142
+ The first row of quality evaluation results can be reproduced using the following commands corresponding to the different target models:
143
+ ```
144
+ superpoint-pruning evaluate
145
+ superpoint-pruning evaluate --hierarchical
146
+ superpoint-pruning evaluate --backbone_0_1 32 --hierarchical
147
+ superpoint-pruning evaluate --pruning-config ./src/superpoint_pruning/weights/16_16_24_32_64.ckpt --hierarchical
148
+ superpoint-pruning evaluate --pruning-config ./src/superpoint_pruning/weights/16_16_24_32_64.ckpt --hierarchical --skip-refinement
149
+ ```
150
+ Note that in this script and other similar ones, we can define the pruning either with format `--backbone_x_y new_input_channel_size` or by pointing it to a ckpt file named in the format `a_b_...ckpt`, which is translated to `--backbone_0_1 a`, `--backbone_1_0 b` and so on.
151
+ For the evaluation using the outdoor trace (or some other trace), the `--image-dir` parameter has to be specified including the other specific options to use the same image range (`--no-skip --start-idx 1000 --end-idx 1500`).
152
+
153
+ ## Plot keypoint comparisons
154
+ For a quick keypoint prediction comparsions plot between a target model and the original on a specific image, one can for example use:
155
+ ```
156
+ superpoint-pruning plot --image-name image_1_1200.png --output ./test.png --num-keypoints 512 --pruning-config ./src/superpoint_pruning/weights/16_16_24_32_64.ckpt --overlay
157
+ ```
158
+ This once again defaults to the UZH FPV Indoor forward facing trace #3 and accepts file names from the dataset.
159
+
160
+ ## Export model to ONNX
161
+ To export the desired PyTorch model to ONNX:
162
+ ```
163
+ superpoint-pruning export --backbone_0_1 32
164
+ ```
165
+ The model pruning configuration can once again be controlled with same flags as shown in the evaluation example.
166
+
167
+ ## Run TRT inference benchmark
168
+ In case the device supports TensorRT (with the necessary packages installed) and there is a serialized TRT model (here a dummy example of `superpoint.engine`), then the local python benchmark with a TRT wrapper can be run as:
169
+ ```
170
+ superpoint-pruning benchmark --model-path superpoint.engine
171
+ ```
pyproject.toml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "superpoint-pruning"
7
+ version = "0.1.0"
8
+ description = "Pruning and distillation for SuperPoint"
9
+ requires-python = ">=3.10"
10
+
11
+ [tool.setuptools.packages.find]
12
+ where = ["src"]
13
+
14
+ [project.scripts]
15
+ superpoint-pruning = "superpoint_pruning.cli:main"
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Base dependencies for export, distillation, evaluation, and plotting.
2
+ # Install with: pip install -r requirements.txt
3
+ # On Jetson, install NVIDIA's PyTorch first and skip the torch line if it is already present.
4
+ numpy
5
+ tqdm
6
+ PyYAML
7
+ matplotlib
8
+ opencv-python
9
+ lightning
10
+ onnx
11
+ torch==2.8.0
src/__init__.py ADDED
File without changes
src/superpoint_pruning/__init__.py ADDED
File without changes
src/superpoint_pruning/cli.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+
3
+ from superpoint_pruning.evaluation import eval as eval_mod
4
+ from superpoint_pruning import export as export_mod
5
+ from superpoint_pruning.distillation import setup as setup_mod
6
+ from superpoint_pruning.distillation import lightning_trainer as train_mod
7
+ from superpoint_pruning.evaluation import benchmark_sp_tensorrt as benchmark_mod
8
+ from superpoint_pruning.evaluation import plot_keypoints as plot_keypoints_mod
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(prog="superpoint-pruning")
13
+ subparsers = parser.add_subparsers(dest="command", required=True)
14
+
15
+ eval_parser = subparsers.add_parser("evaluate", help="Run SuperPoint evaluation")
16
+ eval_mod.add_parser_args(eval_parser)
17
+ eval_parser.set_defaults(func=eval_mod.main)
18
+
19
+ export_parser = subparsers.add_parser("export", help="Export SuperPoint to ONNX")
20
+ export_mod.add_parser_args(export_parser)
21
+ export_parser.set_defaults(func=export_mod.main)
22
+
23
+ setup_parser = subparsers.add_parser(
24
+ "setup", help="Setup the dataset for distillation and evaluation"
25
+ )
26
+ setup_mod.add_parser_args(setup_parser)
27
+ setup_parser.set_defaults(func=setup_mod.main)
28
+
29
+ train_parser = subparsers.add_parser("train", help="Train a pruned SuperPoint")
30
+ train_mod.add_parser_args(train_parser)
31
+ train_parser.set_defaults(func=train_mod.main)
32
+
33
+ benchmark_parser = subparsers.add_parser(
34
+ "benchmark", help="Benchmark a TensorRT SuperPoint engine"
35
+ )
36
+ benchmark_mod.add_parser_args(benchmark_parser)
37
+ benchmark_parser.set_defaults(func=benchmark_mod.main)
38
+
39
+ plot_parser = subparsers.add_parser(
40
+ "plot", help="Plot original and pruned SuperPoint keypoints"
41
+ )
42
+ plot_keypoints_mod.add_parser_args(plot_parser)
43
+ plot_parser.set_defaults(func=plot_keypoints_mod.main)
44
+
45
+ args = parser.parse_args()
46
+ args.func(args)
src/superpoint_pruning/distillation/__init__.py ADDED
File without changes
src/superpoint_pruning/distillation/base_config.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+
3
+ model:
4
+ num_keypoints: 512
5
+
6
+ prune:
7
+ backbone_0_1: 16
8
+ backbone_1_0: 16
9
+ backbone_1_1: 24
10
+ backbone_2_0: 32
11
+ backbone_2_1: 64
12
+
13
+ data:
14
+ train_image_dir: "data/datasets/indoor_forward_3_snapdragon_with_gt/img"
15
+ train_image_ids_file: "data/gt_id_file_train_base_250.txt"
16
+ val_image_dir: "data/datasets/indoor_forward_3_snapdragon_with_gt/img"
17
+ val_image_ids_file: "data/gt_id_file_train_base_250.txt"
18
+ ground_truth_dir: "data"
19
+ keypoints_file: "kpts_train_base_250.npy"
20
+ descriptors_file: "desc_train_base_250.npy"
21
+ array_ids_file: "gt_id_file_train_base_250.txt"
22
+ image_size: [640, 480]
23
+ batch_size: 16
24
+ num_workers: 2
25
+ pin_memory: true
26
+
27
+ optimizer:
28
+ lr: 1.0e-3
29
+ weight_decay: 1.0e-3
30
+
31
+ loss:
32
+ cross_entropy_coef: 2.0
33
+ kl_coef: 2.0
34
+ descriptor_coef: 1.0
35
+
36
+ trainer:
37
+ max_epochs: 300
38
+ accelerator: "auto"
39
+ devices: 1
40
+ precision: "32-true"
41
+ log_every_n_steps: 5
42
+ default_root_dir: "./distillation_runs"
src/superpoint_pruning/distillation/data/train_base.txt ADDED
@@ -0,0 +1,1575 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ image_1_1752.png
2
+ image_0_1682.png
3
+ image_0_1303.png
4
+ image_1_1541.png
5
+ image_0_1102.png
6
+ image_0_1554.png
7
+ image_1_1299.png
8
+ image_1_1721.png
9
+ image_0_1856.png
10
+ image_1_1924.png
11
+ image_0_1729.png
12
+ image_0_1392.png
13
+ image_1_1116.png
14
+ image_0_1387.png
15
+ image_0_1923.png
16
+ image_0_1859.png
17
+ image_1_1071.png
18
+ image_0_1041.png
19
+ image_1_1128.png
20
+ image_1_1181.png
21
+ image_1_1845.png
22
+ image_1_1847.png
23
+ image_0_1038.png
24
+ image_0_1939.png
25
+ image_1_1427.png
26
+ image_1_1588.png
27
+ image_1_1169.png
28
+ image_1_1568.png
29
+ image_0_1919.png
30
+ image_1_1013.png
31
+ image_0_1587.png
32
+ image_0_1386.png
33
+ image_0_1943.png
34
+ image_1_1274.png
35
+ image_0_1920.png
36
+ image_0_1139.png
37
+ image_1_1892.png
38
+ image_0_1997.png
39
+ image_1_1261.png
40
+ image_0_1022.png
41
+ image_0_1214.png
42
+ image_0_1132.png
43
+ image_0_1495.png
44
+ image_1_1108.png
45
+ image_1_1756.png
46
+ image_0_1750.png
47
+ image_0_1963.png
48
+ image_1_1761.png
49
+ image_1_1315.png
50
+ image_0_1959.png
51
+ image_1_1722.png
52
+ image_0_1929.png
53
+ image_0_1895.png
54
+ image_0_1164.png
55
+ image_0_1331.png
56
+ image_0_1532.png
57
+ image_0_1144.png
58
+ image_1_1186.png
59
+ image_0_1466.png
60
+ image_0_1055.png
61
+ image_1_1867.png
62
+ image_0_1546.png
63
+ image_0_1595.png
64
+ image_1_1888.png
65
+ image_1_1611.png
66
+ image_1_1615.png
67
+ image_1_1848.png
68
+ image_0_1179.png
69
+ image_0_1675.png
70
+ image_0_1460.png
71
+ image_0_1142.png
72
+ image_1_1868.png
73
+ image_1_1309.png
74
+ image_1_1237.png
75
+ image_0_1236.png
76
+ image_0_1215.png
77
+ image_0_1123.png
78
+ image_1_1529.png
79
+ image_0_1590.png
80
+ image_0_1057.png
81
+ image_1_1006.png
82
+ image_1_1928.png
83
+ image_1_1776.png
84
+ image_1_1757.png
85
+ image_0_1044.png
86
+ image_0_1036.png
87
+ image_1_1294.png
88
+ image_1_1100.png
89
+ image_0_1950.png
90
+ image_1_1194.png
91
+ image_1_1968.png
92
+ image_0_1027.png
93
+ image_0_1643.png
94
+ image_0_1527.png
95
+ image_0_1207.png
96
+ image_1_1346.png
97
+ image_0_1660.png
98
+ image_0_1228.png
99
+ image_1_1417.png
100
+ image_0_1368.png
101
+ image_1_1523.png
102
+ image_0_1421.png
103
+ image_0_1972.png
104
+ image_1_1462.png
105
+ image_0_1016.png
106
+ image_0_1630.png
107
+ image_1_1911.png
108
+ image_1_1954.png
109
+ image_0_1948.png
110
+ image_0_1809.png
111
+ image_0_1822.png
112
+ image_1_1491.png
113
+ image_1_1022.png
114
+ image_0_1621.png
115
+ image_0_1362.png
116
+ image_1_1745.png
117
+ image_0_1081.png
118
+ image_0_1562.png
119
+ image_1_1790.png
120
+ image_0_1909.png
121
+ image_1_1494.png
122
+ image_0_1378.png
123
+ image_0_1450.png
124
+ image_1_1146.png
125
+ image_0_1459.png
126
+ image_0_1503.png
127
+ image_1_1688.png
128
+ image_0_1600.png
129
+ image_0_1089.png
130
+ image_1_1880.png
131
+ image_0_1879.png
132
+ image_0_1379.png
133
+ image_1_1540.png
134
+ image_0_1115.png
135
+ image_0_1741.png
136
+ image_0_1861.png
137
+ image_0_1581.png
138
+ image_1_1645.png
139
+ image_0_1867.png
140
+ image_1_1048.png
141
+ image_0_1275.png
142
+ image_0_1073.png
143
+ image_0_1912.png
144
+ image_0_1565.png
145
+ image_0_1500.png
146
+ image_0_1603.png
147
+ image_1_1479.png
148
+ image_0_1656.png
149
+ image_0_1582.png
150
+ image_1_1425.png
151
+ image_1_1003.png
152
+ image_1_1130.png
153
+ image_0_1003.png
154
+ image_1_1148.png
155
+ image_1_1905.png
156
+ image_1_1641.png
157
+ image_1_1599.png
158
+ image_0_1956.png
159
+ image_0_1286.png
160
+ image_1_1104.png
161
+ image_1_1827.png
162
+ image_1_1455.png
163
+ image_1_1778.png
164
+ image_0_1588.png
165
+ image_1_1875.png
166
+ image_0_1395.png
167
+ image_0_1760.png
168
+ image_0_1677.png
169
+ image_0_1188.png
170
+ image_0_1700.png
171
+ image_0_1672.png
172
+ image_1_1957.png
173
+ image_0_1993.png
174
+ image_1_1987.png
175
+ image_1_1706.png
176
+ image_1_1421.png
177
+ image_1_1978.png
178
+ image_0_1357.png
179
+ image_1_1591.png
180
+ image_0_1086.png
181
+ image_0_1464.png
182
+ image_0_1699.png
183
+ image_1_1434.png
184
+ image_1_1857.png
185
+ image_0_1978.png
186
+ image_1_1247.png
187
+ image_0_1611.png
188
+ image_0_1278.png
189
+ image_0_1502.png
190
+ image_1_1578.png
191
+ image_1_1530.png
192
+ image_0_1735.png
193
+ image_1_1962.png
194
+ image_1_1903.png
195
+ image_0_1725.png
196
+ image_0_1279.png
197
+ image_1_1582.png
198
+ image_1_1040.png
199
+ image_1_1946.png
200
+ image_0_1320.png
201
+ image_0_1238.png
202
+ image_1_1550.png
203
+ image_1_1808.png
204
+ image_0_1727.png
205
+ image_0_1190.png
206
+ image_0_1348.png
207
+ image_1_1930.png
208
+ image_1_1948.png
209
+ image_1_1449.png
210
+ image_1_1974.png
211
+ image_1_1670.png
212
+ image_1_1552.png
213
+ image_0_1522.png
214
+ image_0_1973.png
215
+ image_0_1734.png
216
+ image_0_1796.png
217
+ image_0_1801.png
218
+ image_1_1564.png
219
+ image_1_1236.png
220
+ image_0_1471.png
221
+ image_0_1468.png
222
+ image_0_1960.png
223
+ image_0_1807.png
224
+ image_0_1833.png
225
+ image_0_1905.png
226
+ image_0_1768.png
227
+ image_1_1750.png
228
+ image_0_1137.png
229
+ image_1_1230.png
230
+ image_0_1114.png
231
+ image_1_1769.png
232
+ image_1_1686.png
233
+ image_0_1441.png
234
+ image_1_1956.png
235
+ image_1_1458.png
236
+ image_1_1316.png
237
+ image_1_1809.png
238
+ image_0_1052.png
239
+ image_0_1515.png
240
+ image_0_1152.png
241
+ image_0_1918.png
242
+ image_0_1337.png
243
+ image_1_1807.png
244
+ image_0_1767.png
245
+ image_1_1595.png
246
+ image_1_1637.png
247
+ image_1_1823.png
248
+ image_0_1283.png
249
+ image_1_1073.png
250
+ image_0_1366.png
251
+ image_0_1370.png
252
+ image_0_1186.png
253
+ image_1_1514.png
254
+ image_1_1337.png
255
+ image_0_1234.png
256
+ image_1_1942.png
257
+ image_1_1597.png
258
+ image_1_1303.png
259
+ image_0_1393.png
260
+ image_0_1947.png
261
+ image_1_1679.png
262
+ image_1_1354.png
263
+ image_0_1196.png
264
+ image_0_1195.png
265
+ image_1_1245.png
266
+ image_0_1406.png
267
+ image_0_1116.png
268
+ image_1_1664.png
269
+ image_0_1162.png
270
+ image_1_1152.png
271
+ image_0_1622.png
272
+ image_1_1310.png
273
+ image_0_1457.png
274
+ image_0_1074.png
275
+ image_0_1451.png
276
+ image_1_1105.png
277
+ image_1_1051.png
278
+ image_1_1803.png
279
+ image_1_1037.png
280
+ image_1_1830.png
281
+ image_0_1145.png
282
+ image_0_1541.png
283
+ image_1_1557.png
284
+ image_1_1285.png
285
+ image_1_1587.png
286
+ image_1_1437.png
287
+ image_1_1559.png
288
+ image_1_1306.png
289
+ image_0_1167.png
290
+ image_1_1380.png
291
+ image_0_1284.png
292
+ image_0_1006.png
293
+ image_1_1496.png
294
+ image_0_1641.png
295
+ image_0_1658.png
296
+ image_1_1047.png
297
+ image_1_1370.png
298
+ image_1_1155.png
299
+ image_1_1453.png
300
+ image_1_1257.png
301
+ image_1_1359.png
302
+ image_0_1812.png
303
+ image_0_1063.png
304
+ image_1_1573.png
305
+ image_0_1092.png
306
+ image_0_1248.png
307
+ image_0_1509.png
308
+ image_0_1001.png
309
+ image_1_1286.png
310
+ image_1_1166.png
311
+ image_0_1550.png
312
+ image_0_1628.png
313
+ image_1_1382.png
314
+ image_1_1439.png
315
+ image_0_1151.png
316
+ image_1_1785.png
317
+ image_0_1635.png
318
+ image_0_1780.png
319
+ image_1_1433.png
320
+ image_1_1086.png
321
+ image_1_1558.png
322
+ image_1_1210.png
323
+ image_0_1753.png
324
+ image_0_1563.png
325
+ image_0_1689.png
326
+ image_0_1813.png
327
+ image_1_1387.png
328
+ image_1_1915.png
329
+ image_1_1579.png
330
+ image_1_1206.png
331
+ image_0_1002.png
332
+ image_0_1992.png
333
+ image_0_1229.png
334
+ image_1_1394.png
335
+ image_0_1795.png
336
+ image_1_1565.png
337
+ image_1_1406.png
338
+ image_1_1445.png
339
+ image_0_1013.png
340
+ image_0_1280.png
341
+ image_0_1783.png
342
+ image_1_1897.png
343
+ image_1_1604.png
344
+ image_1_1996.png
345
+ image_1_1632.png
346
+ image_0_1521.png
347
+ image_1_1327.png
348
+ image_0_1764.png
349
+ image_1_1068.png
350
+ image_1_1214.png
351
+ image_0_1798.png
352
+ image_0_1819.png
353
+ image_0_1434.png
354
+ image_0_1511.png
355
+ image_1_1959.png
356
+ image_0_1070.png
357
+ image_0_1692.png
358
+ image_0_1815.png
359
+ image_0_1315.png
360
+ image_1_1521.png
361
+ image_1_1717.png
362
+ image_0_1033.png
363
+ image_0_1951.png
364
+ image_1_1952.png
365
+ image_0_1203.png
366
+ image_0_1175.png
367
+ image_1_1876.png
368
+ image_1_1649.png
369
+ image_0_1523.png
370
+ image_0_1720.png
371
+ image_1_1793.png
372
+ image_0_1419.png
373
+ image_0_1994.png
374
+ image_1_1739.png
375
+ image_0_1520.png
376
+ image_1_1918.png
377
+ image_0_1719.png
378
+ image_1_1730.png
379
+ image_1_1275.png
380
+ image_1_1012.png
381
+ image_0_1223.png
382
+ image_1_1674.png
383
+ image_1_1883.png
384
+ image_0_1166.png
385
+ image_1_1603.png
386
+ image_0_1383.png
387
+ image_1_1312.png
388
+ image_0_1129.png
389
+ image_1_1289.png
390
+ image_1_1250.png
391
+ image_1_1628.png
392
+ image_0_1226.png
393
+ image_1_1889.png
394
+ image_1_1443.png
395
+ image_1_1376.png
396
+ image_1_1096.png
397
+ image_0_1721.png
398
+ image_0_1031.png
399
+ image_0_1804.png
400
+ image_0_1717.png
401
+ image_0_1130.png
402
+ image_0_1642.png
403
+ image_1_1834.png
404
+ image_0_1980.png
405
+ image_0_1707.png
406
+ image_1_1358.png
407
+ image_0_1192.png
408
+ image_1_1546.png
409
+ image_1_1334.png
410
+ image_0_1695.png
411
+ image_1_1467.png
412
+ image_1_1478.png
413
+ image_1_1690.png
414
+ image_0_1090.png
415
+ image_0_1954.png
416
+ image_0_1099.png
417
+ image_1_1383.png
418
+ image_1_1280.png
419
+ image_0_1825.png
420
+ image_1_1060.png
421
+ image_1_1544.png
422
+ image_1_1507.png
423
+ image_0_1967.png
424
+ image_0_1619.png
425
+ image_1_1967.png
426
+ image_0_1479.png
427
+ image_0_1270.png
428
+ image_0_1847.png
429
+ image_0_1638.png
430
+ image_1_1940.png
431
+ image_1_1574.png
432
+ image_1_1298.png
433
+ image_1_1045.png
434
+ image_0_1350.png
435
+ image_1_1080.png
436
+ image_1_1511.png
437
+ image_1_1137.png
438
+ image_1_1601.png
439
+ image_0_1542.png
440
+ image_1_1636.png
441
+ image_0_1217.png
442
+ image_0_1206.png
443
+ image_1_1758.png
444
+ image_1_1995.png
445
+ image_1_1147.png
446
+ image_1_1972.png
447
+ image_1_1392.png
448
+ image_0_1917.png
449
+ image_1_1869.png
450
+ image_0_1744.png
451
+ image_0_1811.png
452
+ image_0_1359.png
453
+ image_1_1618.png
454
+ image_0_1043.png
455
+ image_1_1542.png
456
+ image_0_1210.png
457
+ image_1_1476.png
458
+ image_0_1854.png
459
+ image_0_1456.png
460
+ image_0_1014.png
461
+ image_0_1623.png
462
+ image_0_1892.png
463
+ image_0_1932.png
464
+ image_0_1497.png
465
+ image_1_1667.png
466
+ image_1_1567.png
467
+ image_0_1470.png
468
+ image_1_1084.png
469
+ image_0_1824.png
470
+ image_1_1420.png
471
+ image_0_1962.png
472
+ image_0_1463.png
473
+ image_0_1539.png
474
+ image_1_1788.png
475
+ image_1_1894.png
476
+ image_1_1454.png
477
+ image_0_1032.png
478
+ image_0_1551.png
479
+ image_1_1348.png
480
+ image_0_1173.png
481
+ image_0_1181.png
482
+ image_0_1836.png
483
+ image_1_1413.png
484
+ image_1_1362.png
485
+ image_0_1608.png
486
+ image_0_1657.png
487
+ image_0_1606.png
488
+ image_1_1719.png
489
+ image_0_1525.png
490
+ image_0_1382.png
491
+ image_1_1654.png
492
+ image_1_1512.png
493
+ image_1_1833.png
494
+ image_0_1417.png
495
+ image_1_1818.png
496
+ image_0_1204.png
497
+ image_1_1920.png
498
+ image_1_1533.png
499
+ image_1_1561.png
500
+ image_1_1222.png
501
+ image_1_1816.png
502
+ image_1_1555.png
503
+ image_0_1483.png
504
+ image_1_1492.png
505
+ image_0_1472.png
506
+ image_0_1916.png
507
+ image_0_1762.png
508
+ image_1_1017.png
509
+ image_0_1792.png
510
+ image_1_1242.png
511
+ image_0_1852.png
512
+ image_1_1372.png
513
+ image_0_1530.png
514
+ image_1_1630.png
515
+ image_0_1375.png
516
+ image_1_1772.png
517
+ image_0_1373.png
518
+ image_1_1945.png
519
+ image_1_1749.png
520
+ image_0_1096.png
521
+ image_0_1263.png
522
+ image_0_1965.png
523
+ image_1_1246.png
524
+ image_1_1910.png
525
+ image_0_1281.png
526
+ image_0_1076.png
527
+ image_0_1494.png
528
+ image_0_1816.png
529
+ image_0_1868.png
530
+ image_1_1356.png
531
+ image_1_1057.png
532
+ image_1_1302.png
533
+ image_1_1267.png
534
+ image_0_1938.png
535
+ image_0_1633.png
536
+ image_1_1142.png
537
+ image_0_1258.png
538
+ image_0_1491.png
539
+ image_0_1851.png
540
+ image_0_1821.png
541
+ image_0_1333.png
542
+ image_0_1747.png
543
+ image_0_1991.png
544
+ image_1_1851.png
545
+ image_1_1094.png
546
+ image_0_1399.png
547
+ image_0_1059.png
548
+ image_1_1343.png
549
+ image_0_1156.png
550
+ image_0_1713.png
551
+ image_0_1416.png
552
+ image_0_1714.png
553
+ image_0_1952.png
554
+ image_1_1989.png
555
+ image_0_1244.png
556
+ image_1_1701.png
557
+ image_0_1300.png
558
+ image_0_1647.png
559
+ image_1_1446.png
560
+ image_0_1165.png
561
+ image_1_1682.png
562
+ image_1_1646.png
563
+ image_1_1125.png
564
+ image_0_1285.png
565
+ image_1_1196.png
566
+ image_1_1866.png
567
+ image_0_1047.png
568
+ image_1_1231.png
569
+ image_0_1135.png
570
+ image_1_1087.png
571
+ image_1_1500.png
572
+ image_1_1357.png
573
+ image_0_1893.png
574
+ image_0_1071.png
575
+ image_0_1191.png
576
+ image_1_1039.png
577
+ image_1_1639.png
578
+ image_0_1449.png
579
+ image_0_1155.png
580
+ image_0_1061.png
581
+ image_1_1426.png
582
+ image_0_1222.png
583
+ image_1_1190.png
584
+ image_0_1789.png
585
+ image_0_1941.png
586
+ image_0_1988.png
587
+ image_1_1062.png
588
+ image_1_1079.png
589
+ image_1_1290.png
590
+ image_0_1591.png
591
+ image_0_1908.png
592
+ image_1_1694.png
593
+ image_1_1650.png
594
+ image_1_1122.png
595
+ image_1_1014.png
596
+ image_0_1696.png
597
+ image_1_1183.png
598
+ image_0_1394.png
599
+ image_1_1342.png
600
+ image_0_1018.png
601
+ image_0_1087.png
602
+ image_0_1037.png
603
+ image_1_1070.png
604
+ image_1_1384.png
605
+ image_1_1218.png
606
+ image_0_1189.png
607
+ image_0_1363.png
608
+ image_0_1012.png
609
+ image_1_1388.png
610
+ image_1_1058.png
611
+ image_1_1715.png
612
+ image_1_1410.png
613
+ image_0_1853.png
614
+ image_0_1589.png
615
+ image_0_1496.png
616
+ image_1_1678.png
617
+ image_0_1930.png
618
+ image_1_1208.png
619
+ image_1_1470.png
620
+ image_0_1024.png
621
+ image_0_1650.png
622
+ image_0_1516.png
623
+ image_0_1678.png
624
+ image_0_1492.png
625
+ image_1_1873.png
626
+ image_0_1594.png
627
+ image_1_1904.png
628
+ image_1_1031.png
629
+ image_1_1885.png
630
+ image_0_1119.png
631
+ image_0_1644.png
632
+ image_0_1906.png
633
+ image_1_1377.png
634
+ image_1_1279.png
635
+ image_1_1538.png
636
+ image_1_1986.png
637
+ image_1_1887.png
638
+ image_1_1802.png
639
+ image_1_1441.png
640
+ image_0_1325.png
641
+ image_1_1490.png
642
+ image_0_1060.png
643
+ image_0_1610.png
644
+ image_1_1549.png
645
+ image_1_1509.png
646
+ image_0_1831.png
647
+ image_0_1476.png
648
+ image_1_1754.png
649
+ image_1_1389.png
650
+ image_0_1710.png
651
+ image_1_1318.png
652
+ image_1_1513.png
653
+ image_1_1687.png
654
+ image_0_1842.png
655
+ image_1_1759.png
656
+ image_0_1062.png
657
+ image_1_1705.png
658
+ image_1_1179.png
659
+ image_0_1519.png
660
+ image_0_1051.png
661
+ image_0_1596.png
662
+ image_1_1055.png
663
+ image_1_1225.png
664
+ image_0_1999.png
665
+ image_0_1732.png
666
+ image_1_1771.png
667
+ image_1_1097.png
668
+ image_1_1608.png
669
+ image_0_1774.png
670
+ image_0_1308.png
671
+ image_0_1239.png
672
+ image_0_1564.png
673
+ image_0_1770.png
674
+ image_1_1899.png
675
+ image_0_1612.png
676
+ image_0_1841.png
677
+ image_0_1974.png
678
+ image_1_1273.png
679
+ image_1_1009.png
680
+ image_1_1074.png
681
+ image_1_1607.png
682
+ image_0_1766.png
683
+ image_0_1671.png
684
+ image_1_1944.png
685
+ image_1_1891.png
686
+ image_0_1475.png
687
+ image_0_1781.png
688
+ image_1_1993.png
689
+ image_0_1458.png
690
+ image_1_1301.png
691
+ image_1_1284.png
692
+ image_1_1629.png
693
+ image_1_1249.png
694
+ image_1_1184.png
695
+ image_1_1076.png
696
+ image_0_1334.png
697
+ image_0_1049.png
698
+ image_1_1861.png
699
+ image_0_1172.png
700
+ image_0_1761.png
701
+ image_0_1585.png
702
+ image_1_1056.png
703
+ image_0_1971.png
704
+ image_0_1517.png
705
+ image_1_1605.png
706
+ image_0_1436.png
707
+ image_0_1282.png
708
+ image_0_1803.png
709
+ image_1_1676.png
710
+ image_1_1390.png
711
+ image_1_1515.png
712
+ image_1_1475.png
713
+ image_1_1154.png
714
+ image_0_1025.png
715
+ image_1_1235.png
716
+ image_1_1320.png
717
+ image_1_1409.png
718
+ image_1_1452.png
719
+ image_1_1120.png
720
+ image_0_1910.png
721
+ image_0_1986.png
722
+ image_1_1525.png
723
+ image_1_1175.png
724
+ image_0_1444.png
725
+ image_1_1723.png
726
+ image_0_1873.png
727
+ image_0_1722.png
728
+ image_0_1914.png
729
+ image_1_1336.png
730
+ image_0_1567.png
731
+ image_0_1322.png
732
+ image_1_1020.png
733
+ image_1_1167.png
734
+ image_1_1369.png
735
+ image_0_1935.png
736
+ image_1_1191.png
737
+ image_0_1898.png
738
+ image_1_1232.png
739
+ image_0_1122.png
740
+ image_1_1840.png
741
+ image_0_1618.png
742
+ image_0_1535.png
743
+ image_1_1419.png
744
+ image_1_1941.png
745
+ image_0_1931.png
746
+ image_1_1672.png
747
+ image_0_1069.png
748
+ image_0_1936.png
749
+ image_1_1976.png
750
+ image_1_1797.png
751
+ image_1_1613.png
752
+ image_1_1328.png
753
+ image_1_1829.png
754
+ image_1_1638.png
755
+ image_1_1842.png
756
+ image_0_1662.png
757
+ image_1_1075.png
758
+ image_0_1745.png
759
+ image_1_1177.png
760
+ image_0_1381.png
761
+ image_1_1753.png
762
+ image_0_1634.png
763
+ image_0_1302.png
764
+ image_0_1109.png
765
+ image_1_1660.png
766
+ image_1_1252.png
767
+ image_1_1657.png
768
+ image_1_1831.png
769
+ image_0_1877.png
770
+ image_0_1712.png
771
+ image_1_1489.png
772
+ image_1_1018.png
773
+ image_1_1988.png
774
+ image_0_1340.png
775
+ image_1_1616.png
776
+ image_0_1324.png
777
+ image_1_1625.png
778
+ image_0_1583.png
779
+ image_1_1792.png
780
+ image_0_1826.png
781
+ image_1_1935.png
782
+ image_0_1560.png
783
+ image_0_1358.png
784
+ image_0_1984.png
785
+ image_1_1459.png
786
+ image_0_1065.png
787
+ image_0_1314.png
788
+ image_1_1795.png
789
+ image_1_1726.png
790
+ image_1_1260.png
791
+ image_1_1317.png
792
+ image_1_1371.png
793
+ image_1_1272.png
794
+ image_0_1404.png
795
+ image_1_1164.png
796
+ image_0_1654.png
797
+ image_1_1432.png
798
+ image_0_1093.png
799
+ image_0_1084.png
800
+ image_1_1240.png
801
+ image_0_1886.png
802
+ image_1_1925.png
803
+ image_1_1197.png
804
+ image_0_1869.png
805
+ image_1_1030.png
806
+ image_0_1171.png
807
+ image_0_1524.png
808
+ image_1_1725.png
809
+ image_0_1659.png
810
+ image_1_1937.png
811
+ image_1_1204.png
812
+ image_1_1583.png
813
+ image_1_1428.png
814
+ image_1_1465.png
815
+ image_1_1871.png
816
+ image_1_1916.png
817
+ image_1_1182.png
818
+ image_0_1158.png
819
+ image_0_1570.png
820
+ image_0_1140.png
821
+ image_1_1695.png
822
+ image_1_1783.png
823
+ image_1_1736.png
824
+ image_0_1299.png
825
+ image_1_1766.png
826
+ image_0_1846.png
827
+ image_1_1050.png
828
+ image_1_1734.png
829
+ image_0_1443.png
830
+ image_1_1448.png
831
+ image_0_1547.png
832
+ image_0_1601.png
833
+ image_1_1408.png
834
+ image_0_1850.png
835
+ image_1_1127.png
836
+ image_1_1620.png
837
+ image_0_1023.png
838
+ image_0_1778.png
839
+ image_0_1017.png
840
+ image_0_1970.png
841
+ image_0_1733.png
842
+ image_1_1393.png
843
+ image_0_1505.png
844
+ image_1_1168.png
845
+ image_1_1170.png
846
+ image_1_1870.png
847
+ image_0_1293.png
848
+ image_0_1019.png
849
+ image_1_1364.png
850
+ image_0_1773.png
851
+ image_0_1414.png
852
+ image_1_1621.png
853
+ image_1_1044.png
854
+ image_1_1227.png
855
+ image_0_1377.png
856
+ image_0_1704.png
857
+ image_1_1107.png
858
+ image_1_1234.png
859
+ image_0_1730.png
860
+ image_1_1189.png
861
+ image_0_1213.png
862
+ image_0_1298.png
863
+ image_0_1785.png
864
+ image_1_1367.png
865
+ image_1_1997.png
866
+ image_0_1982.png
867
+ image_0_1998.png
868
+ image_1_1737.png
869
+ image_1_1858.png
870
+ image_0_1976.png
871
+ image_1_1864.png
872
+ image_1_1207.png
873
+ image_0_1445.png
874
+ image_1_1853.png
875
+ image_1_1822.png
876
+ image_1_1139.png
877
+ image_1_1893.png
878
+ image_0_1058.png
879
+ image_1_1572.png
880
+ image_1_1345.png
881
+ image_0_1740.png
882
+ image_0_1085.png
883
+ image_0_1556.png
884
+ image_1_1516.png
885
+ image_1_1053.png
886
+ image_0_1987.png
887
+ image_0_1631.png
888
+ image_0_1053.png
889
+ image_0_1141.png
890
+ image_1_1780.png
891
+ image_0_1703.png
892
+ image_1_1590.png
893
+ image_1_1675.png
894
+ image_1_1027.png
895
+ image_0_1548.png
896
+ image_0_1291.png
897
+ image_1_1415.png
898
+ image_0_1420.png
899
+ image_0_1161.png
900
+ image_0_1356.png
901
+ image_0_1143.png
902
+ image_1_1407.png
903
+ image_0_1499.png
904
+ image_0_1313.png
905
+ image_1_1092.png
906
+ image_0_1227.png
907
+ image_0_1199.png
908
+ image_1_1131.png
909
+ image_0_1498.png
910
+ image_1_1052.png
911
+ image_1_1258.png
912
+ image_1_1979.png
913
+ image_0_1462.png
914
+ image_1_1651.png
915
+ image_1_1763.png
916
+ image_1_1217.png
917
+ image_0_1814.png
918
+ image_0_1891.png
919
+ image_0_1429.png
920
+ image_0_1020.png
921
+ image_1_1609.png
922
+ image_1_1291.png
923
+ image_1_1517.png
924
+ image_0_1346.png
925
+ image_1_1497.png
926
+ image_0_1169.png
927
+ image_0_1985.png
928
+ image_1_1466.png
929
+ image_0_1668.png
930
+ image_1_1661.png
931
+ image_0_1864.png
932
+ image_0_1412.png
933
+ image_1_1874.png
934
+ image_0_1942.png
935
+ image_0_1937.png
936
+ image_0_1323.png
937
+ image_1_1036.png
938
+ image_1_1980.png
939
+ image_1_1163.png
940
+ image_1_1681.png
941
+ image_1_1919.png
942
+ image_1_1221.png
943
+ image_0_1111.png
944
+ image_1_1852.png
945
+ image_0_1620.png
946
+ image_1_1539.png
947
+ image_0_1413.png
948
+ image_0_1579.png
949
+ image_0_1400.png
950
+ image_0_1410.png
951
+ image_0_1326.png
952
+ image_1_1375.png
953
+ image_0_1646.png
954
+ image_1_1488.png
955
+ image_0_1834.png
956
+ image_1_1635.png
957
+ image_0_1469.png
958
+ image_1_1806.png
959
+ image_0_1230.png
960
+ image_0_1157.png
961
+ image_1_1431.png
962
+ image_0_1665.png
963
+ image_0_1118.png
964
+ image_0_1112.png
965
+ image_0_1233.png
966
+ image_1_1648.png
967
+ image_1_1964.png
968
+ image_1_1856.png
969
+ image_0_1946.png
970
+ image_1_1553.png
971
+ image_0_1355.png
972
+ image_1_1707.png
973
+ image_0_1154.png
974
+ image_1_1323.png
975
+ image_0_1708.png
976
+ image_0_1534.png
977
+ image_1_1832.png
978
+ image_0_1273.png
979
+ image_1_1716.png
980
+ image_0_1609.png
981
+ image_0_1232.png
982
+ image_1_1135.png
983
+ image_0_1996.png
984
+ image_1_1444.png
985
+ image_0_1095.png
986
+ image_1_1054.png
987
+ image_1_1161.png
988
+ image_1_1019.png
989
+ image_1_1038.png
990
+ image_0_1673.png
991
+ image_1_1460.png
992
+ image_1_1619.png
993
+ image_1_1349.png
994
+ image_0_1624.png
995
+ image_0_1514.png
996
+ image_1_1877.png
997
+ image_0_1094.png
998
+ image_0_1823.png
999
+ image_0_1926.png
1000
+ image_0_1454.png
1001
+ image_1_1566.png
1002
+ image_0_1797.png
1003
+ image_0_1254.png
1004
+ image_1_1782.png
1005
+ image_1_1837.png
1006
+ image_1_1322.png
1007
+ image_1_1209.png
1008
+ image_0_1015.png
1009
+ image_0_1629.png
1010
+ image_1_1815.png
1011
+ image_0_1294.png
1012
+ image_0_1584.png
1013
+ image_0_1105.png
1014
+ image_0_1398.png
1015
+ image_1_1640.png
1016
+ image_0_1353.png
1017
+ image_1_1878.png
1018
+ image_1_1614.png
1019
+ image_0_1321.png
1020
+ image_0_1433.png
1021
+ image_1_1606.png
1022
+ image_0_1683.png
1023
+ image_1_1560.png
1024
+ image_0_1701.png
1025
+ image_0_1255.png
1026
+ image_0_1655.png
1027
+ image_1_1263.png
1028
+ image_1_1817.png
1029
+ image_0_1010.png
1030
+ image_0_1150.png
1031
+ image_1_1069.png
1032
+ image_0_1810.png
1033
+ image_0_1686.png
1034
+ image_0_1706.png
1035
+ image_0_1345.png
1036
+ image_1_1321.png
1037
+ image_0_1625.png
1038
+ image_0_1901.png
1039
+ image_1_1473.png
1040
+ image_1_1537.png
1041
+ image_1_1786.png
1042
+ image_1_1810.png
1043
+ image_1_1110.png
1044
+ image_1_1825.png
1045
+ image_0_1510.png
1046
+ image_0_1301.png
1047
+ image_1_1811.png
1048
+ image_0_1465.png
1049
+ image_0_1666.png
1050
+ image_1_1140.png
1051
+ image_1_1297.png
1052
+ image_0_1736.png
1053
+ image_1_1900.png
1054
+ image_0_1977.png
1055
+ image_1_1212.png
1056
+ image_1_1751.png
1057
+ image_1_1647.png
1058
+ image_0_1488.png
1059
+ image_1_1332.png
1060
+ image_0_1124.png
1061
+ image_1_1268.png
1062
+ image_1_1109.png
1063
+ image_1_1789.png
1064
+ image_0_1617.png
1065
+ image_1_1259.png
1066
+ image_1_1998.png
1067
+ image_1_1102.png
1068
+ image_1_1729.png
1069
+ image_0_1292.png
1070
+ image_1_1099.png
1071
+ image_0_1883.png
1072
+ image_0_1265.png
1073
+ image_0_1857.png
1074
+ image_0_1177.png
1075
+ image_0_1928.png
1076
+ image_1_1451.png
1077
+ image_0_1779.png
1078
+ image_0_1288.png
1079
+ image_1_1063.png
1080
+ image_0_1388.png
1081
+ image_1_1338.png
1082
+ image_0_1545.png
1083
+ image_1_1774.png
1084
+ image_0_1174.png
1085
+ image_0_1453.png
1086
+ image_0_1489.png
1087
+ image_1_1580.png
1088
+ image_1_1821.png
1089
+ image_1_1485.png
1090
+ image_1_1879.png
1091
+ image_0_1537.png
1092
+ image_1_1028.png
1093
+ image_0_1264.png
1094
+ image_0_1208.png
1095
+ image_1_1325.png
1096
+ image_0_1924.png
1097
+ image_1_1922.png
1098
+ image_0_1341.png
1099
+ image_1_1078.png
1100
+ image_1_1344.png
1101
+ image_1_1180.png
1102
+ image_0_1990.png
1103
+ image_1_1360.png
1104
+ image_0_1648.png
1105
+ image_0_1828.png
1106
+ image_0_1793.png
1107
+ image_0_1344.png
1108
+ image_0_1549.png
1109
+ image_1_1784.png
1110
+ image_0_1045.png
1111
+ image_0_1403.png
1112
+ image_1_1007.png
1113
+ image_0_1685.png
1114
+ image_0_1578.png
1115
+ image_1_1229.png
1116
+ image_0_1755.png
1117
+ image_1_1983.png
1118
+ image_1_1335.png
1119
+ image_1_1961.png
1120
+ image_0_1800.png
1121
+ image_0_1080.png
1122
+ image_1_1700.png
1123
+ image_1_1025.png
1124
+ image_1_1300.png
1125
+ image_0_1136.png
1126
+ image_0_1289.png
1127
+ image_1_1308.png
1128
+ image_0_1437.png
1129
+ image_0_1439.png
1130
+ image_1_1531.png
1131
+ image_0_1424.png
1132
+ image_1_1865.png
1133
+ image_0_1688.png
1134
+ image_0_1163.png
1135
+ image_1_1733.png
1136
+ image_1_1188.png
1137
+ image_0_1533.png
1138
+ image_1_1141.png
1139
+ image_1_1768.png
1140
+ image_0_1543.png
1141
+ image_1_1846.png
1142
+ image_0_1575.png
1143
+ image_0_1007.png
1144
+ image_0_1765.png
1145
+ image_0_1335.png
1146
+ image_0_1955.png
1147
+ image_1_1819.png
1148
+ image_1_1838.png
1149
+ image_0_1480.png
1150
+ image_1_1794.png
1151
+ image_0_1904.png
1152
+ image_0_1602.png
1153
+ image_0_1922.png
1154
+ image_1_1991.png
1155
+ image_1_1932.png
1156
+ image_0_1253.png
1157
+ image_1_1882.png
1158
+ image_0_1775.png
1159
+ image_1_1385.png
1160
+ image_1_1543.png
1161
+ image_0_1128.png
1162
+ image_0_1409.png
1163
+ image_1_1251.png
1164
+ image_0_1402.png
1165
+ image_0_1328.png
1166
+ image_0_1508.png
1167
+ image_0_1440.png
1168
+ image_0_1607.png
1169
+ image_1_1826.png
1170
+ image_0_1913.png
1171
+ image_0_1272.png
1172
+ image_1_1011.png
1173
+ image_0_1369.png
1174
+ image_1_1610.png
1175
+ image_1_1424.png
1176
+ image_0_1209.png
1177
+ image_0_1040.png
1178
+ image_0_1501.png
1179
+ image_1_1077.png
1180
+ image_1_1314.png
1181
+ image_1_1510.png
1182
+ image_1_1576.png
1183
+ image_0_1452.png
1184
+ image_1_1243.png
1185
+ image_0_1949.png
1186
+ image_1_1066.png
1187
+ image_0_1940.png
1188
+ image_1_1781.png
1189
+ image_0_1571.png
1190
+ image_0_1802.png
1191
+ image_1_1943.png
1192
+ image_0_1652.png
1193
+ image_1_1642.png
1194
+ image_0_1506.png
1195
+ image_0_1243.png
1196
+ image_0_1056.png
1197
+ image_1_1813.png
1198
+ image_1_1755.png
1199
+ image_0_1327.png
1200
+ image_1_1046.png
1201
+ image_1_1653.png
1202
+ image_0_1316.png
1203
+ image_1_1760.png
1204
+ image_1_1145.png
1205
+ image_0_1290.png
1206
+ image_1_1939.png
1207
+ image_1_1123.png
1208
+ image_1_1958.png
1209
+ image_1_1524.png
1210
+ image_1_1293.png
1211
+ image_0_1907.png
1212
+ image_1_1255.png
1213
+ image_1_1594.png
1214
+ image_1_1652.png
1215
+ image_1_1401.png
1216
+ image_0_1106.png
1217
+ image_0_1054.png
1218
+ image_1_1340.png
1219
+ image_1_1311.png
1220
+ image_1_1934.png
1221
+ image_0_1261.png
1222
+ image_1_1728.png
1223
+ image_1_1082.png
1224
+ image_1_1775.png
1225
+ image_0_1639.png
1226
+ image_0_1844.png
1227
+ image_1_1977.png
1228
+ image_0_1435.png
1229
+ image_0_1405.png
1230
+ image_1_1487.png
1231
+ image_0_1113.png
1232
+ image_1_1593.png
1233
+ image_0_1878.png
1234
+ image_1_1277.png
1235
+ image_1_1468.png
1236
+ image_0_1005.png
1237
+ image_0_1277.png
1238
+ image_1_1307.png
1239
+ image_0_1349.png
1240
+ image_1_1339.png
1241
+ image_0_1737.png
1242
+ image_1_1960.png
1243
+ image_1_1791.png
1244
+ image_0_1961.png
1245
+ image_1_1113.png
1246
+ image_0_1848.png
1247
+ image_1_1352.png
1248
+ image_1_1153.png
1249
+ image_0_1574.png
1250
+ image_0_1026.png
1251
+ image_1_1481.png
1252
+ image_0_1097.png
1253
+ image_0_1690.png
1254
+ image_1_1378.png
1255
+ image_1_1223.png
1256
+ image_1_1411.png
1257
+ image_1_1034.png
1258
+ image_0_1107.png
1259
+ image_0_1390.png
1260
+ image_1_1347.png
1261
+ image_0_1536.png
1262
+ image_1_1305.png
1263
+ image_1_1200.png
1264
+ image_1_1292.png
1265
+ image_1_1764.png
1266
+ image_1_1093.png
1267
+ image_1_1265.png
1268
+ image_1_1368.png
1269
+ image_0_1146.png
1270
+ image_0_1259.png
1271
+ image_0_1758.png
1272
+ image_1_1534.png
1273
+ image_0_1576.png
1274
+ image_1_1463.png
1275
+ image_1_1435.png
1276
+ image_0_1147.png
1277
+ image_1_1504.png
1278
+ image_0_1339.png
1279
+ image_0_1426.png
1280
+ image_1_1562.png
1281
+ image_0_1431.png
1282
+ image_1_1953.png
1283
+ image_0_1415.png
1284
+ image_1_1912.png
1285
+ image_1_1241.png
1286
+ image_1_1696.png
1287
+ image_1_1015.png
1288
+ image_1_1185.png
1289
+ image_1_1032.png
1290
+ image_0_1902.png
1291
+ image_0_1669.png
1292
+ image_1_1907.png
1293
+ image_0_1225.png
1294
+ image_1_1898.png
1295
+ image_1_1689.png
1296
+ image_1_1859.png
1297
+ image_0_1371.png
1298
+ image_1_1254.png
1299
+ image_1_1836.png
1300
+ image_1_1035.png
1301
+ image_0_1742.png
1302
+ image_0_1262.png
1303
+ image_0_1216.png
1304
+ image_0_1372.png
1305
+ image_1_1677.png
1306
+ image_1_1709.png
1307
+ image_0_1438.png
1308
+ image_1_1596.png
1309
+ image_1_1438.png
1310
+ image_0_1964.png
1311
+ image_1_1520.png
1312
+ image_0_1446.png
1313
+ image_1_1746.png
1314
+ image_0_1481.png
1315
+ image_1_1741.png
1316
+ image_1_1144.png
1317
+ image_0_1805.png
1318
+ image_0_1531.png
1319
+ image_0_1160.png
1320
+ image_0_1716.png
1321
+ image_1_1505.png
1322
+ image_1_1416.png
1323
+ image_0_1461.png
1324
+ image_0_1138.png
1325
+ image_1_1024.png
1326
+ image_0_1772.png
1327
+ image_0_1794.png
1328
+ image_1_1262.png
1329
+ image_1_1992.png
1330
+ image_0_1231.png
1331
+ image_1_1350.png
1332
+ image_1_1101.png
1333
+ image_1_1917.png
1334
+ image_0_1257.png
1335
+ image_0_1830.png
1336
+ image_1_1547.png
1337
+ image_0_1552.png
1338
+ image_1_1329.png
1339
+ image_1_1160.png
1340
+ image_0_1849.png
1341
+ image_0_1711.png
1342
+ image_1_1042.png
1343
+ image_0_1442.png
1344
+ image_1_1351.png
1345
+ image_1_1577.png
1346
+ image_1_1502.png
1347
+ image_0_1311.png
1348
+ image_0_1818.png
1349
+ image_0_1110.png
1350
+ image_0_1121.png
1351
+ image_0_1885.png
1352
+ image_1_1973.png
1353
+ image_0_1245.png
1354
+ image_1_1623.png
1355
+ image_0_1008.png
1356
+ image_0_1427.png
1357
+ image_0_1702.png
1358
+ image_1_1442.png
1359
+ image_0_1927.png
1360
+ image_0_1969.png
1361
+ image_1_1205.png
1362
+ image_0_1423.png
1363
+ image_0_1028.png
1364
+ image_1_1950.png
1365
+ image_0_1064.png
1366
+ image_0_1193.png
1367
+ image_0_1559.png
1368
+ image_1_1486.png
1369
+ image_0_1784.png
1370
+ image_1_1043.png
1371
+ image_1_1201.png
1372
+ image_1_1115.png
1373
+ image_1_1361.png
1374
+ image_0_1187.png
1375
+ image_1_1933.png
1376
+ image_1_1396.png
1377
+ image_0_1385.png
1378
+ image_0_1256.png
1379
+ image_0_1200.png
1380
+ image_0_1418.png
1381
+ image_0_1820.png
1382
+ image_1_1269.png
1383
+ image_1_1386.png
1384
+ image_1_1365.png
1385
+ image_0_1310.png
1386
+ image_1_1287.png
1387
+ image_0_1989.png
1388
+ image_0_1598.png
1389
+ image_1_1016.png
1390
+ image_0_1477.png
1391
+ image_1_1740.png
1392
+ image_0_1679.png
1393
+ image_1_1324.png
1394
+ image_0_1389.png
1395
+ image_1_1072.png
1396
+ image_1_1908.png
1397
+ image_0_1957.png
1398
+ image_0_1079.png
1399
+ image_0_1718.png
1400
+ image_1_1697.png
1401
+ image_0_1561.png
1402
+ image_0_1738.png
1403
+ image_1_1414.png
1404
+ image_1_1929.png
1405
+ image_0_1343.png
1406
+ image_1_1049.png
1407
+ image_1_1627.png
1408
+ image_0_1769.png
1409
+ image_1_1238.png
1410
+ image_0_1380.png
1411
+ image_0_1724.png
1412
+ image_1_1430.png
1413
+ image_0_1983.png
1414
+ image_0_1979.png
1415
+ image_0_1731.png
1416
+ image_1_1450.png
1417
+ image_1_1304.png
1418
+ image_0_1945.png
1419
+ image_1_1671.png
1420
+ image_1_1767.png
1421
+ image_1_1288.png
1422
+ image_1_1622.png
1423
+ image_1_1151.png
1424
+ image_0_1271.png
1425
+ image_0_1108.png
1426
+ image_0_1843.png
1427
+ image_0_1958.png
1428
+ image_1_1644.png
1429
+ image_1_1896.png
1430
+ image_1_1693.png
1431
+ image_1_1134.png
1432
+ image_1_1718.png
1433
+ image_1_1403.png
1434
+ image_0_1739.png
1435
+ image_1_1570.png
1436
+ image_0_1776.png
1437
+ image_0_1786.png
1438
+ image_1_1563.png
1439
+ image_1_1712.png
1440
+ image_1_1828.png
1441
+ image_1_1483.png
1442
+ image_1_1955.png
1443
+ image_1_1172.png
1444
+ image_0_1104.png
1445
+ image_0_1408.png
1446
+ image_0_1862.png
1447
+ image_1_1931.png
1448
+ image_0_1485.png
1449
+ image_1_1963.png
1450
+ image_0_1478.png
1451
+ image_0_1697.png
1452
+ image_1_1326.png
1453
+ image_1_1226.png
1454
+ image_1_1724.png
1455
+ image_1_1703.png
1456
+ image_0_1194.png
1457
+ image_0_1705.png
1458
+ image_1_1173.png
1459
+ image_0_1306.png
1460
+ image_0_1752.png
1461
+ image_0_1787.png
1462
+ image_1_1199.png
1463
+ image_0_1295.png
1464
+ image_0_1674.png
1465
+ image_0_1319.png
1466
+ image_1_1770.png
1467
+ image_1_1106.png
1468
+ image_1_1133.png
1469
+ image_1_1021.png
1470
+ image_0_1153.png
1471
+ image_1_1655.png
1472
+ image_0_1572.png
1473
+ image_0_1068.png
1474
+ image_0_1715.png
1475
+ image_1_1814.png
1476
+ image_0_1127.png
1477
+ image_1_1373.png
1478
+ image_1_1748.png
1479
+ image_1_1909.png
1480
+ image_0_1513.png
1481
+ image_1_1121.png
1482
+ image_0_1220.png
1483
+ image_1_1440.png
1484
+ image_0_1709.png
1485
+ image_1_1464.png
1486
+ image_0_1131.png
1487
+ image_0_1035.png
1488
+ image_0_1903.png
1489
+ image_1_1192.png
1490
+ image_1_1276.png
1491
+ image_0_1799.png
1492
+ image_0_1863.png
1493
+ image_0_1728.png
1494
+ image_1_1743.png
1495
+ image_0_1746.png
1496
+ image_0_1894.png
1497
+ image_1_1503.png
1498
+ image_0_1180.png
1499
+ image_1_1295.png
1500
+ image_0_1078.png
1501
+ image_1_1178.png
1502
+ image_1_1886.png
1503
+ image_0_1077.png
1504
+ image_1_1926.png
1505
+ image_0_1676.png
1506
+ image_0_1944.png
1507
+ image_0_1870.png
1508
+ image_0_1367.png
1509
+ image_0_1640.png
1510
+ image_1_1233.png
1511
+ image_1_1461.png
1512
+ image_0_1829.png
1513
+ image_1_1283.png
1514
+ image_0_1680.png
1515
+ image_0_1184.png
1516
+ image_0_1201.png
1517
+ image_0_1615.png
1518
+ image_0_1890.png
1519
+ image_1_1584.png
1520
+ image_0_1088.png
1521
+ image_0_1882.png
1522
+ image_1_1198.png
1523
+ image_1_1119.png
1524
+ image_1_1085.png
1525
+ image_0_1632.png
1526
+ image_0_1235.png
1527
+ image_0_1518.png
1528
+ image_0_1881.png
1529
+ image_1_1132.png
1530
+ image_1_1203.png
1531
+ image_0_1875.png
1532
+ image_0_1616.png
1533
+ image_1_1714.png
1534
+ image_1_1456.png
1535
+ image_0_1330.png
1536
+ image_1_1668.png
1537
+ image_0_1149.png
1538
+ image_1_1474.png
1539
+ image_1_1698.png
1540
+ image_1_1498.png
1541
+ image_1_1149.png
1542
+ image_1_1422.png
1543
+ image_0_1447.png
1544
+ image_0_1197.png
1545
+ image_0_1249.png
1546
+ image_0_1332.png
1547
+ image_1_1493.png
1548
+ image_1_1800.png
1549
+ image_1_1602.png
1550
+ image_1_1732.png
1551
+ image_0_1422.png
1552
+ image_1_1405.png
1553
+ image_0_1839.png
1554
+ image_1_1429.png
1555
+ image_1_1617.png
1556
+ image_1_1683.png
1557
+ image_0_1777.png
1558
+ image_0_1614.png
1559
+ image_0_1512.png
1560
+ image_1_1969.png
1561
+ image_0_1880.png
1562
+ image_1_1399.png
1563
+ image_1_1219.png
1564
+ image_1_1519.png
1565
+ image_0_1580.png
1566
+ image_1_1585.png
1567
+ image_0_1360.png
1568
+ image_1_1684.png
1569
+ image_1_1624.png
1570
+ image_1_1176.png
1571
+ image_0_1039.png
1572
+ image_0_1297.png
1573
+ image_0_1693.png
1574
+ image_0_1296.png
1575
+ image_1_1143.png
src/superpoint_pruning/distillation/lightning_trainer.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ from pathlib import Path
3
+ from typing import Any
4
+ import numpy as np
5
+ import torch
6
+ from torch.utils.data import DataLoader, Dataset
7
+ import yaml
8
+ import lightning as pl
9
+ import os
10
+
11
+ from superpoint_pruning.models.superpoint import SuperPoint
12
+ from superpoint_pruning.distillation.utils import rescale_image, load_grayscale_image
13
+ from superpoint_pruning.distillation.losses import (
14
+ detector_loss_simple,
15
+ descriptor_loss_simple,
16
+ detector_kd_kl,
17
+ )
18
+ from superpoint_pruning.paths import DEFAULT_CONFIG_PATH
19
+
20
+ DATA_PATH_KEYS = (
21
+ "train_image_dir",
22
+ "train_image_ids_file",
23
+ "val_image_dir",
24
+ "val_image_ids_file",
25
+ "ground_truth_dir",
26
+ )
27
+
28
+
29
+ def resolve_path(base: Path, value: str | Path) -> Path:
30
+ path = Path(value)
31
+ if not path.is_absolute():
32
+ path = base / path
33
+ return path.resolve()
34
+
35
+
36
+ def load_config(path: str | Path, data_root: Path | None = None) -> dict[str, Any]:
37
+ config_path = Path(path).resolve()
38
+ with open(config_path, encoding="utf-8") as f:
39
+ cfg = yaml.safe_load(f)
40
+
41
+ base = data_root.resolve() if data_root is not None else config_path.parent
42
+ for key in DATA_PATH_KEYS:
43
+ cfg["data"][key] = str(resolve_path(base, cfg["data"][key]))
44
+ cfg["trainer"]["default_root_dir"] = str(
45
+ resolve_path(config_path.parent, cfg["trainer"]["default_root_dir"])
46
+ )
47
+ return cfg
48
+
49
+
50
+ class ImageFolderDataset(Dataset):
51
+ """Simple grayscale dataset from an image directory."""
52
+
53
+ def __init__(
54
+ self,
55
+ image_dir: str,
56
+ image_ids_file: str,
57
+ ground_truth_dir: str,
58
+ keypoints_file: str,
59
+ descriptors_file: str,
60
+ array_ids_file: str,
61
+ image_size: tuple[int, int],
62
+ ) -> None:
63
+ self.image_ids = Path(image_ids_file).read_text().splitlines()
64
+ self.image_dir = image_dir
65
+ self.ground_truth_dir = Path(ground_truth_dir)
66
+ self.keypoint_logits = np.load(
67
+ self.ground_truth_dir / keypoints_file, mmap_mode="r"
68
+ )
69
+ self.descriptor_logits = np.load(
70
+ self.ground_truth_dir / descriptors_file, mmap_mode="r"
71
+ )
72
+ self.image_size = image_size
73
+
74
+ array_ids_file = self.ground_truth_dir / array_ids_file
75
+ if array_ids_file.exists():
76
+ array_ids = array_ids_file.read_text().splitlines()
77
+ self.array_indices = {
78
+ image_id: idx for idx, image_id in enumerate(array_ids)
79
+ }
80
+ else:
81
+ if len(self.image_ids) > len(self.keypoint_logits):
82
+ raise ValueError(
83
+ "The image id list is longer than the ground-truth arrays."
84
+ )
85
+ self.array_indices = {
86
+ image_id: idx for idx, image_id in enumerate(self.image_ids)
87
+ }
88
+
89
+ def __len__(self) -> int:
90
+ return len(self.image_ids)
91
+
92
+ def __getitem__(
93
+ self, idx: int
94
+ ) -> tuple[torch.Tensor, Any, torch.Tensor, torch.Tensor]:
95
+ image_id = self.image_ids[idx]
96
+ image_path = os.path.join(self.image_dir, image_id)
97
+ image = load_grayscale_image(image_path)
98
+ img, scale = rescale_image(image, self.image_size)
99
+ img = img[None, None].astype(np.float32)
100
+ img = torch.from_numpy(img)
101
+
102
+ array_idx = self.array_indices[image_id]
103
+ keypoint_logits = torch.from_numpy(
104
+ np.array(self.keypoint_logits[array_idx], copy=True)
105
+ )
106
+ descriptor_logits = torch.from_numpy(
107
+ np.array(self.descriptor_logits[array_idx], copy=True)
108
+ )
109
+ return img[0], scale, keypoint_logits, descriptor_logits
110
+
111
+
112
+ class SuperPointDataModule(pl.LightningDataModule):
113
+ def __init__(self, cfg: dict[str, Any]) -> None:
114
+ super().__init__()
115
+ self.cfg = cfg
116
+ self.train_ds: ImageFolderDataset | None = None
117
+ self.val_ds: ImageFolderDataset | None = None
118
+
119
+ def setup(self, stage: str | None = None) -> None:
120
+ self.train_ds = ImageFolderDataset(
121
+ image_dir=self.cfg["train_image_dir"],
122
+ image_ids_file=self.cfg["train_image_ids_file"],
123
+ ground_truth_dir=self.cfg["ground_truth_dir"],
124
+ keypoints_file=self.cfg["keypoints_file"],
125
+ descriptors_file=self.cfg["descriptors_file"],
126
+ array_ids_file=self.cfg["array_ids_file"],
127
+ image_size=self.cfg["image_size"],
128
+ )
129
+ self.val_ds = ImageFolderDataset(
130
+ image_dir=self.cfg["val_image_dir"],
131
+ image_ids_file=self.cfg["val_image_ids_file"],
132
+ ground_truth_dir=self.cfg["ground_truth_dir"],
133
+ keypoints_file=self.cfg["keypoints_file"],
134
+ descriptors_file=self.cfg["descriptors_file"],
135
+ array_ids_file=self.cfg["array_ids_file"],
136
+ image_size=self.cfg["image_size"],
137
+ )
138
+
139
+ def train_dataloader(self) -> DataLoader:
140
+ if self.train_ds is None:
141
+ raise RuntimeError("DataModule is not set up.")
142
+ return DataLoader(
143
+ self.train_ds,
144
+ batch_size=self.cfg["batch_size"],
145
+ shuffle=True,
146
+ num_workers=self.cfg["num_workers"],
147
+ pin_memory=self.cfg["pin_memory"],
148
+ drop_last=True,
149
+ )
150
+
151
+ def val_dataloader(self) -> DataLoader:
152
+ if self.val_ds is None:
153
+ raise RuntimeError("DataModule is not set up.")
154
+ return DataLoader(
155
+ self.val_ds,
156
+ batch_size=self.cfg["batch_size"],
157
+ shuffle=False,
158
+ num_workers=self.cfg["num_workers"],
159
+ pin_memory=self.cfg["pin_memory"],
160
+ drop_last=False,
161
+ )
162
+
163
+
164
+ class SuperPointLightningModule(pl.LightningModule):
165
+
166
+ def __init__(self, cfg: dict[str, Any]) -> None:
167
+ super().__init__()
168
+ self.save_hyperparameters(cfg)
169
+ self.cfg = cfg
170
+ self.model = SuperPoint(
171
+ num_keypoints=cfg["model"]["num_keypoints"], return_dense=True
172
+ )
173
+ pruning_config = cfg["model"]["prune"]
174
+ self.model.prune_backbone(pruning_config)
175
+ print(self.model)
176
+
177
+ def forward(self, image: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
178
+ keypoints, descriptors = self.model(image)
179
+ return keypoints, descriptors
180
+
181
+ def _compute_loss(
182
+ self,
183
+ keypoints: torch.Tensor,
184
+ descriptors: torch.Tensor,
185
+ keypoints_gt: torch.Tensor,
186
+ descriptors_gt: torch.Tensor,
187
+ ) -> torch.Tensor:
188
+ score_term_hard = detector_loss_simple(keypoints_gt, keypoints)
189
+ score_term = detector_kd_kl(keypoints_gt, keypoints)
190
+ desc_term = descriptor_loss_simple(descriptors, descriptors_gt)
191
+
192
+ self.log(
193
+ f"train/loss_ce",
194
+ score_term_hard,
195
+ prog_bar=True,
196
+ on_step=True,
197
+ on_epoch=True,
198
+ )
199
+ self.log(
200
+ f"train/loss_kl", score_term, prog_bar=True, on_step=True, on_epoch=True
201
+ )
202
+ self.log(
203
+ f"train/loss_desc", desc_term, prog_bar=True, on_step=True, on_epoch=True
204
+ )
205
+
206
+ return (
207
+ self.cfg["loss"]["cross_entropy_coef"] * score_term_hard
208
+ + self.cfg["loss"]["kl_coef"] * score_term
209
+ + self.cfg["loss"]["descriptor_coef"] * desc_term
210
+ )
211
+
212
+ def _shared_step(self, batch: dict[str, Any], stage: str) -> torch.Tensor:
213
+ images, scales, keypoints_gt, descriptors_gt = batch
214
+ keypoint_logits, descriptor_logits = self.forward(images)
215
+ loss = self._compute_loss(
216
+ keypoint_logits, descriptor_logits, keypoints_gt, descriptors_gt
217
+ )
218
+ self.log(
219
+ f"{stage}/loss",
220
+ loss,
221
+ prog_bar=True,
222
+ on_step=(stage == "train"),
223
+ on_epoch=True,
224
+ )
225
+ self.log(
226
+ f"{stage}/avg_score",
227
+ loss.mean(),
228
+ prog_bar=False,
229
+ on_step=False,
230
+ on_epoch=True,
231
+ )
232
+ return loss
233
+
234
+ def training_step(self, batch: dict[str, Any], batch_idx: int) -> torch.Tensor:
235
+ return self._shared_step(batch, stage="train")
236
+
237
+ def validation_step(self, batch: dict[str, Any], batch_idx: int) -> None:
238
+ self._shared_step(batch, stage="val")
239
+
240
+ def configure_optimizers(self) -> torch.optim.Optimizer:
241
+ trainable_params = [p for p in self.parameters() if p.requires_grad]
242
+ return torch.optim.AdamW(
243
+ trainable_params,
244
+ lr=self.cfg["optimizer"]["lr"],
245
+ weight_decay=self.cfg["optimizer"]["weight_decay"],
246
+ )
247
+
248
+
249
+ def add_parser_args(parser: argparse.ArgumentParser) -> None:
250
+ parser.add_argument(
251
+ "--config", type=Path, default=DEFAULT_CONFIG_PATH, help="Path to YAML config"
252
+ )
253
+ parser.add_argument(
254
+ "--data-root",
255
+ type=Path,
256
+ default=None,
257
+ help="Base for relative data paths in the config. Default: directory of --config.",
258
+ )
259
+
260
+
261
+ def main(args: argparse.Namespace) -> None:
262
+ cfg = load_config(args.config, data_root=args.data_root)
263
+
264
+ pl.seed_everything(cfg["seed"], workers=True)
265
+ data_module = SuperPointDataModule(cfg["data"])
266
+ lightning_module = SuperPointLightningModule(cfg)
267
+
268
+ trainer = pl.Trainer(
269
+ max_epochs=cfg["trainer"]["max_epochs"],
270
+ accelerator=cfg["trainer"]["accelerator"],
271
+ devices=cfg["trainer"]["devices"],
272
+ precision=cfg["trainer"]["precision"],
273
+ log_every_n_steps=cfg["trainer"]["log_every_n_steps"],
274
+ default_root_dir=cfg["trainer"]["default_root_dir"],
275
+ # enable_checkpointing=False,
276
+ logger=True,
277
+ limit_val_batches=0,
278
+ )
279
+ trainer.fit(model=lightning_module, datamodule=data_module)
280
+
281
+
282
+ if __name__ == "__main__":
283
+ parser = argparse.ArgumentParser()
284
+ add_parser_args(parser)
285
+ main(parser.parse_args())
src/superpoint_pruning/distillation/losses.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+
4
+
5
+ def convert_keypoints_to_(
6
+ gt: torch.Tensor, H: int = 768, W: int = 1024
7
+ ) -> torch.Tensor:
8
+ B, N, _ = gt.shape
9
+ xy = gt.round().long()
10
+ x = xy[..., 0].clamp(0, W - 1)
11
+ y = xy[..., 1].clamp(0, H - 1)
12
+ keypoint_map = torch.zeros((B, H, W), device=gt.device, dtype=torch.float32)
13
+ b = torch.arange(B, device=gt.device).unsqueeze(1).expand(B, N)
14
+ keypoint_map[b, y, x] = 1.0
15
+ return keypoint_map
16
+
17
+
18
+ def detector_loss(
19
+ keypoint_map: torch.Tensor, # (B,H,W) or (B,1,H,W), binary/bool
20
+ logits: torch.Tensor, # (B,65,Hc,Wc), raw convPb output
21
+ valid_mask: torch.Tensor | None = None, # (B,H,W) or (B,1,H,W)
22
+ grid_size: int = 8,
23
+ eps: float = 1e-8,
24
+ ) -> torch.Tensor:
25
+ # --- labels: space_to_depth + dustbin + random tie-break + argmax ---
26
+ if keypoint_map.ndim == 3:
27
+ keypoint_map = keypoint_map.unsqueeze(1) # (B,1,H,W)
28
+ keypoint_map = keypoint_map.float()
29
+ # TF NHWC space_to_depth -> PyTorch NCHW pixel_unshuffle
30
+ labels = F.pixel_unshuffle(keypoint_map, downscale_factor=grid_size) # (B,64,Hc,Wc)
31
+ dustbin = torch.ones_like(labels[:, :1]) # (B,1,Hc,Wc)
32
+ labels = torch.cat([2.0 * labels, dustbin], dim=1) # (B,65,Hc,Wc)
33
+ # same tie-break idea as TF random_uniform(..., 0, 0.1)
34
+ labels = torch.argmax(
35
+ labels + 0.1 * torch.rand_like(labels), dim=1
36
+ ) # (B,Hc,Wc), long
37
+ # --- valid mask path ---
38
+ if valid_mask is None:
39
+ valid_mask = torch.ones_like(keypoint_map)
40
+ elif valid_mask.ndim == 3:
41
+ valid_mask = valid_mask.unsqueeze(1)
42
+ valid_mask = valid_mask.float()
43
+ valid_mask = F.pixel_unshuffle(
44
+ valid_mask, downscale_factor=grid_size
45
+ ) # (B,64,Hc,Wc)
46
+ valid_mask = torch.prod(valid_mask, dim=1) # (B,Hc,Wc)
47
+ # --- sparse softmax cross entropy with weights ---
48
+ per_cell = F.cross_entropy(logits, labels, reduction="none") # (B,Hc,Wc)
49
+ weighted = per_cell * valid_mask
50
+ loss = weighted.sum() / valid_mask.sum().clamp_min(1.0 + eps)
51
+ return loss
52
+
53
+
54
+ def detector_loss_simple(
55
+ gt_logits: torch.Tensor,
56
+ pred_logits: torch.Tensor, # (B,65,Hc,Wc), raw convPb output
57
+ ) -> torch.Tensor:
58
+ gt_labels = torch.argmax(
59
+ gt_logits + 0.1 * torch.rand_like(gt_logits), dim=1
60
+ ) # (B,Hc,Wc), long
61
+ return F.cross_entropy(pred_logits, gt_labels, reduction="mean")
62
+
63
+
64
+ def detector_kd_kl(teacher_logits, student_logits, T=2.0, valid_mask=None, eps=1e-8):
65
+ # student_logits, teacher_logits: (B,65,Hc,Wc)
66
+ log_p_s = F.log_softmax(student_logits / T, dim=1)
67
+ p_t = F.softmax(teacher_logits / T, dim=1)
68
+
69
+ # KL per cell: (B,Hc,Wc)
70
+ kl_map = F.kl_div(log_p_s, p_t, reduction="none").sum(dim=1)
71
+
72
+ if valid_mask is None:
73
+ return (T * T) * kl_map.mean()
74
+
75
+ # valid_mask expected (B,H,W) or (B,1,H,W), convert to cell mask (B,Hc,Wc)
76
+ if valid_mask.ndim == 3:
77
+ valid_mask = valid_mask.unsqueeze(1)
78
+ vm = F.pixel_unshuffle(valid_mask.float(), downscale_factor=8) # (B,64,Hc,Wc)
79
+ vm = torch.prod(vm, dim=1) # (B,Hc,Wc)
80
+
81
+ return (T * T) * (kl_map * vm).sum() / vm.sum().clamp_min(1.0 + eps)
82
+
83
+
84
+ def descriptor_loss_simple(
85
+ pred_desc: torch.Tensor, # (B, D, Hc, Wc)
86
+ gt_desc: torch.Tensor, # (B, D, Hc, Wc)
87
+ valid_mask: torch.Tensor | None = None, # (B,H,W) or (B,1,H,W)
88
+ grid_size: int = 8,
89
+ eps: float = 1e-8,
90
+ ) -> torch.Tensor:
91
+ pred = F.normalize(pred_desc, p=2, dim=1)
92
+ gt = F.normalize(gt_desc, p=2, dim=1)
93
+
94
+ # cosine distance per cell
95
+ per_cell = 1.0 - (pred * gt).sum(dim=1) # (B, Hc, Wc)
96
+
97
+ if valid_mask is None:
98
+ return per_cell.mean()
99
+
100
+ if valid_mask.ndim == 3:
101
+ valid_mask = valid_mask.unsqueeze(1) # (B,1,H,W)
102
+
103
+ vm = F.pixel_unshuffle(
104
+ valid_mask.float(), downscale_factor=grid_size
105
+ ) # (B,64,Hc,Wc)
106
+ vm = torch.prod(vm, dim=1) # (B,Hc,Wc)
107
+
108
+ return (per_cell * vm).sum() / vm.sum().clamp_min(1.0 + eps)
109
+
110
+
111
+ def descriptor_loss(
112
+ descriptors: torch.Tensor, # (B, D, Hc, Wc), student
113
+ target_descriptors: torch.Tensor, # (B, D, Hc, Wc), teacher/GT
114
+ valid_mask: torch.Tensor | None = None, # (B,H,W) or (B,1,H,W)
115
+ grid_size: int = 8,
116
+ positive_margin: float = 1.0,
117
+ negative_margin: float = 0.2,
118
+ lambda_d: float = 0.05,
119
+ eps: float = 1e-8,
120
+ ) -> torch.Tensor:
121
+ B, D, Hc, Wc = descriptors.shape
122
+ HW = Hc * Wc
123
+ # L2 normalize descriptors
124
+ desc = F.normalize(descriptors, p=2, dim=1) # (B,D,Hc,Wc)
125
+ tgt = F.normalize(target_descriptors, p=2, dim=1) # (B,D,Hc,Wc)
126
+ # Flatten spatial dims
127
+ desc = desc.flatten(2).transpose(1, 2) # (B,HW,D)
128
+ tgt = tgt.flatten(2).transpose(1, 2) # (B,HW,D)
129
+ # Pairwise dot products: (B,HW,HW)
130
+ dot = torch.bmm(desc, tgt.transpose(1, 2))
131
+ dot = F.relu(dot)
132
+ # TF does double normalization over pairwise axes
133
+ dot = F.normalize(dot, p=2, dim=2)
134
+ dot = F.normalize(dot, p=2, dim=1)
135
+ # Identity correspondence mask s (diagonal)
136
+ eye = torch.eye(HW, device=dot.device, dtype=dot.dtype).unsqueeze(0) # (1,HW,HW)
137
+ s = eye.expand(B, -1, -1)
138
+ positive_dist = F.relu(positive_margin - dot)
139
+ negative_dist = F.relu(dot - negative_margin)
140
+ pairwise_loss = (
141
+ lambda_d * s * positive_dist + (1.0 - s) * negative_dist
142
+ ) # (B,HW,HW)
143
+ # valid mask: same logic as TF space_to_depth + reduce_prod
144
+ if valid_mask is None:
145
+ vm = torch.ones(
146
+ (B, Hc * grid_size, Wc * grid_size), device=dot.device, dtype=dot.dtype
147
+ )
148
+ else:
149
+ vm = valid_mask
150
+ if vm.ndim == 3:
151
+ vm = vm.unsqueeze(1) # (B,1,H,W)
152
+ vm = vm.float()
153
+ vm = F.pixel_unshuffle(vm, downscale_factor=grid_size) # (B,grid^2,Hc,Wc)
154
+ vm = torch.prod(vm, dim=1) # (B,Hc,Wc)
155
+ vm = vm.reshape(B, HW) # valid target cells
156
+ vm = vm[:, None, :] # (B,1,HW), broadcast to (B,HW,HW)
157
+ normalization = vm.sum() * float(HW) + eps
158
+ loss = (pairwise_loss * vm).sum() / normalization
159
+ return loss
src/superpoint_pruning/distillation/setup.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import os
3
+ from pathlib import Path
4
+ from superpoint_pruning.distillation.utils import rescale_image, load_grayscale_image
5
+ from superpoint_pruning.models.superpoint import SuperPoint
6
+ from superpoint_pruning.paths import DATA_ROOT, DEFAULT_DATASET_NAME
7
+ import numpy as np
8
+ import torch
9
+ from tqdm import tqdm
10
+
11
+ import urllib.request
12
+ import zipfile
13
+ import argparse
14
+
15
+ DEFAULT_DATASET_URL = "http://rpg.ifi.uzh.ch/datasets/uzh-fpv-newer-versions/v3/indoor_forward_3_snapdragon_with_gt.zip"
16
+ DEFAULT_GT_INDEX_FILE = "train_base.txt"
17
+
18
+
19
+ def download_dataset_from_url(url: str, output_dir: Path) -> None:
20
+
21
+ zip_path = DATA_ROOT / "download.zip"
22
+ if output_dir.exists():
23
+ print(
24
+ f"Dataset directory {output_dir.resolve()} already exists. Skipping download."
25
+ )
26
+ return
27
+ output_dir.mkdir(parents=True, exist_ok=True)
28
+ print(f"Downloading dataset from {url} to {zip_path.resolve()}")
29
+ urllib.request.urlretrieve(url, zip_path)
30
+ print(f"Extracting files from {zip_path.resolve()} to {output_dir.resolve()}")
31
+ with zipfile.ZipFile(zip_path, "r") as archive:
32
+ archive.extractall(output_dir)
33
+ zip_path.unlink()
34
+ print(f"Files extracted to {output_dir.resolve()}")
35
+
36
+
37
+ def generate_data_split(
38
+ image_dir: Path, split_ratio: float = 0.8
39
+ ) -> tuple[list[str], list[str]]:
40
+ random.seed(42)
41
+ images = [path.name for path in image_dir.glob("*.png")]
42
+ random.shuffle(images)
43
+ split_index = int(len(images) * split_ratio)
44
+ train_images = images[:split_index]
45
+ val_images = images[split_index:]
46
+
47
+ with open(DATA_ROOT / "train.txt", "w") as f:
48
+ for image in train_images:
49
+ f.write(image + "\n")
50
+ with open(DATA_ROOT / "val.txt", "w") as f:
51
+ for image in val_images:
52
+ f.write(image + "\n")
53
+
54
+ print(
55
+ f"Train images: {len(train_images)}, Val images: {len(val_images)} - Saved to {DATA_ROOT}"
56
+ )
57
+
58
+
59
+ @torch.inference_mode()
60
+ def generate_ground_truth(
61
+ image_dir: Path,
62
+ id_file: Path,
63
+ num_keypoints: int = 512,
64
+ image_size: tuple[int, int] = (640, 480),
65
+ num_images: int = 250,
66
+ ) -> None:
67
+
68
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
69
+ images = [path.name for path in image_dir.glob("*.png")]
70
+ model = SuperPoint(num_keypoints=num_keypoints, return_dense=True)
71
+ model.eval().to(device)
72
+
73
+ ids = id_file.read_text().splitlines()
74
+ ids = ids[:num_images]
75
+ id_set = set(ids)
76
+ selected_images = [image_id for image_id in images if image_id in id_set]
77
+ Path(DATA_ROOT / f"gt_id_file_{Path(id_file).stem}_{num_images}.txt").write_text(
78
+ "\n".join(selected_images) + "\n"
79
+ )
80
+
81
+ desc = None
82
+ kpts = None
83
+
84
+ for index, image_id in enumerate(tqdm(selected_images)):
85
+ image_path = os.path.join(image_dir, image_id)
86
+ image = load_grayscale_image(image_path)
87
+ img, _ = rescale_image(image, image_size)
88
+ img = img[None, None].astype(np.float32)
89
+
90
+ img = torch.from_numpy(img).to(device)
91
+
92
+ keypoint_logits, descriptor_logits = model(img)
93
+
94
+ desc_np = descriptor_logits.detach().cpu().numpy()[0]
95
+ kpts_np = keypoint_logits.detach().cpu().numpy()[0]
96
+
97
+ if desc is None or kpts is None:
98
+ desc = np.lib.format.open_memmap(
99
+ DATA_ROOT / f"desc_{id_file.stem}_{num_images}.npy",
100
+ mode="w+",
101
+ dtype=desc_np.dtype,
102
+ shape=(len(selected_images), *desc_np.shape),
103
+ )
104
+ kpts = np.lib.format.open_memmap(
105
+ DATA_ROOT / f"kpts_{id_file.stem}_{num_images}.npy",
106
+ mode="w+",
107
+ dtype=kpts_np.dtype,
108
+ shape=(len(selected_images), *kpts_np.shape),
109
+ )
110
+
111
+ desc[index] = desc_np
112
+ kpts[index] = kpts_np
113
+
114
+ if desc is not None:
115
+ desc.flush()
116
+ if kpts is not None:
117
+ kpts.flush()
118
+
119
+ print(f"Ground truth files generated to {DATA_ROOT}")
120
+
121
+
122
+ def add_parser_args(parser: argparse.ArgumentParser) -> None:
123
+ parser.add_argument("--download-url", type=str, default=DEFAULT_DATASET_URL)
124
+ parser.add_argument("--output-dir-name", type=str, default=DEFAULT_DATASET_NAME)
125
+ parser.add_argument("--gt-index-file", type=str, default=DEFAULT_GT_INDEX_FILE)
126
+ parser.add_argument("--generate-data-split", action="store_true", default=False)
127
+ parser.add_argument("--generate-gt", action="store_true", default=False)
128
+
129
+ parser.add_argument("--num-keypoints", type=int, default=512)
130
+ parser.add_argument("--width", type=int, default=640)
131
+ parser.add_argument("--height", type=int, default=480)
132
+ parser.add_argument("--num-images", type=int, default=250)
133
+
134
+
135
+ def build_parser() -> argparse.ArgumentParser:
136
+ parser = argparse.ArgumentParser(
137
+ description="Setup the dataset for distillation and evaluation."
138
+ )
139
+ add_parser_args(parser)
140
+ return parser
141
+
142
+
143
+ def main(args: argparse.Namespace) -> None:
144
+ if args.download_url and args.output_dir_name:
145
+ download_dataset_from_url(
146
+ args.download_url, DATA_ROOT / "datasets" / args.output_dir_name
147
+ )
148
+ if args.generate_data_split:
149
+ generate_data_split(DATA_ROOT / "datasets" / args.output_dir_name / "img", 0.8)
150
+ if args.generate_gt:
151
+ generate_ground_truth(
152
+ DATA_ROOT / "datasets" / args.output_dir_name / "img",
153
+ DATA_ROOT / args.gt_index_file,
154
+ num_images=args.num_images,
155
+ image_size=(args.width, args.height),
156
+ num_keypoints=args.num_keypoints,
157
+ )
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main(build_parser().parse_args())
src/superpoint_pruning/distillation/utils.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ import cv2
4
+
5
+
6
+ def plot_images(images, titles=None, figsize=None):
7
+ n = len(images)
8
+ if figsize is None:
9
+ figsize = (5 * n, 5)
10
+
11
+ fig, axes = plt.subplots(1, n, figsize=figsize)
12
+ axes = np.atleast_1d(axes)
13
+
14
+ for i, (ax, image) in enumerate(zip(axes, images)):
15
+ if image.ndim == 2:
16
+ ax.imshow(image, cmap="gray")
17
+ else:
18
+ ax.imshow(image)
19
+ if titles is not None:
20
+ ax.set_title(titles[i])
21
+ ax.axis("off")
22
+
23
+ fig.tight_layout()
24
+ return fig, axes
25
+
26
+
27
+ def plot_images_with_keypoints(
28
+ images,
29
+ keypoints,
30
+ titles=None,
31
+ keypoint_size=30,
32
+ figsize=None,
33
+ ):
34
+ fig, axes = plot_images(
35
+ images,
36
+ titles=titles,
37
+ figsize=figsize,
38
+ )
39
+ for ax, points in zip(axes, keypoints):
40
+ points = np.asarray(points)
41
+ if len(points) == 0:
42
+ continue
43
+ x = points[:, 0]
44
+ y = points[:, 1]
45
+
46
+ ax.scatter(x, y, s=keypoint_size, marker=".", color="lime")
47
+ fig.tight_layout()
48
+
49
+
50
+ def load_grayscale_image(path):
51
+ image = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
52
+
53
+ if image is None:
54
+ raise FileNotFoundError(f"Could not read image: {path}")
55
+
56
+ image = image.astype(np.float32) / 255.0
57
+
58
+ return image
59
+
60
+
61
+ def rescale_image(image, new_size=None):
62
+ height, width = image.shape[:2]
63
+ new_width, new_height = new_size
64
+
65
+ resized = cv2.resize(
66
+ image,
67
+ (new_width, new_height),
68
+ interpolation=cv2.INTER_LINEAR,
69
+ )
70
+
71
+ scale_x = new_width / width
72
+ scale_y = new_height / height
73
+
74
+ return resized, (scale_x, scale_y)
src/superpoint_pruning/evaluation/__init__.py ADDED
File without changes
src/superpoint_pruning/evaluation/benchmark_sp_tensorrt.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import numpy as np
3
+ from tqdm import tqdm
4
+ import time
5
+ import os
6
+ import threading
7
+ from queue import Queue, Empty
8
+ from pathlib import Path
9
+
10
+ from superpoint_pruning.distillation.utils import load_grayscale_image, rescale_image
11
+ from superpoint_pruning.paths import DEFAULT_IMAGE_DIR
12
+
13
+ QUEUE_SENTINEL = object()
14
+
15
+
16
+ def benchmark_tensorrt(
17
+ img_dir: str,
18
+ model_path: str,
19
+ max_keypoints: int = 512,
20
+ descriptor_dim: int = 256,
21
+ num_images: int = 1000,
22
+ start_index: int = 1000,
23
+ num_loader_threads: int = 2,
24
+ queue_size: int = 32,
25
+ image_size: tuple = (640, 480),
26
+ ):
27
+
28
+ try:
29
+ from superpoint_pruning.evaluation.onnx_helper import SP_ONNXClassifierWrapper
30
+ except ImportError:
31
+ raise ImportError(
32
+ "ONNX helper requires pycuda and TensorRT utilities installed."
33
+ )
34
+
35
+ trt_model = SP_ONNXClassifierWrapper(
36
+ str(model_path), max_keypoints=max_keypoints, descriptor_dim=descriptor_dim
37
+ )
38
+ times = []
39
+ index_queue = Queue()
40
+ data_queue = Queue(maxsize=max(1, queue_size))
41
+ errors = []
42
+ errors_lock = threading.Lock()
43
+ stop_event = threading.Event()
44
+ processed_images = 0
45
+
46
+ for img_index in range(start_index, start_index + num_images):
47
+ index_queue.put(img_index)
48
+
49
+ effective_loader_threads = max(1, num_loader_threads)
50
+
51
+ def loader_worker():
52
+ try:
53
+ while not stop_event.is_set():
54
+ try:
55
+ img_index = index_queue.get_nowait()
56
+ except Empty:
57
+ break
58
+
59
+ img_path = os.path.join(img_dir, f"image_0_{img_index}.png")
60
+ original = load_grayscale_image(img_path)
61
+ img, scale = rescale_image(original, new_size=image_size)
62
+ img = img[None, None].astype(np.float32)
63
+ data_queue.put((img, scale))
64
+ except Exception as exc:
65
+ with errors_lock:
66
+ errors.append(exc)
67
+ stop_event.set()
68
+ finally:
69
+ data_queue.put(QUEUE_SENTINEL)
70
+
71
+ loader_threads = [
72
+ threading.Thread(target=loader_worker, daemon=True)
73
+ for _ in range(effective_loader_threads)
74
+ ]
75
+
76
+ for thread in loader_threads:
77
+ thread.start()
78
+ try:
79
+ finished_loaders = 0
80
+ with tqdm(total=num_images, desc="Inferencing", unit="img") as pbar:
81
+ while finished_loaders < effective_loader_threads:
82
+ if stop_event.is_set() and data_queue.empty():
83
+ break
84
+
85
+ try:
86
+ item = data_queue.get(timeout=0.1)
87
+ except Empty:
88
+ continue
89
+
90
+ if item is QUEUE_SENTINEL:
91
+ finished_loaders += 1
92
+ continue
93
+
94
+ img, scale = item
95
+ scale = np.array(scale, dtype=np.float32)
96
+ start = time.perf_counter()
97
+ keypoints, _, descriptors = trt_model.predict(img)
98
+ keypoints = (keypoints.astype(np.float32) + 0.5) / scale[None] - 0.5
99
+ stop = time.perf_counter()
100
+ times.append(stop - start)
101
+ processed_images += 1
102
+ pbar.update(1)
103
+
104
+ for thread in loader_threads:
105
+ thread.join()
106
+
107
+ if errors:
108
+ raise RuntimeError(f"Benchmark failed in loader thread: {errors[0]}")
109
+
110
+ if processed_images != num_images:
111
+ raise RuntimeError(
112
+ f"Processed {processed_images}/{num_images} images before stopping."
113
+ )
114
+
115
+ print(f"Average time: {np.array(times).mean()}")
116
+ finally:
117
+ trt_model.close()
118
+
119
+
120
+ def add_parser_args(parser: argparse.ArgumentParser) -> None:
121
+ parser.add_argument("--img-dir", type=Path, default=DEFAULT_IMAGE_DIR)
122
+ parser.add_argument(
123
+ "--model-path",
124
+ type=Path,
125
+ required=True,
126
+ help="Path to the TensorRT SuperPoint engine.",
127
+ )
128
+ parser.add_argument(
129
+ "--max-keypoints",
130
+ type=int,
131
+ default=512,
132
+ help="Maximum number of keypoints from input.",
133
+ )
134
+ parser.add_argument(
135
+ "--descriptor-dim", type=int, default=256, help="Descriptor dimension."
136
+ )
137
+ parser.add_argument(
138
+ "--num-images", type=int, default=1000, help="Number of images to benchmark."
139
+ )
140
+ parser.add_argument(
141
+ "--start-index", type=int, default=1000, help="Start index of the images."
142
+ )
143
+ parser.add_argument(
144
+ "--num-loader-threads",
145
+ type=int,
146
+ default=2,
147
+ help="Number of producer threads for loading and preprocessing images.",
148
+ )
149
+ parser.add_argument(
150
+ "--queue-size",
151
+ type=int,
152
+ default=64,
153
+ help="Max number of preprocessed images buffered for inference.",
154
+ )
155
+ parser.add_argument("--width", type=int, default=640, help="Image width.")
156
+ parser.add_argument("--height", type=int, default=480, help="Image height.")
157
+
158
+
159
+ def main(args: argparse.Namespace) -> None:
160
+ benchmark_tensorrt(
161
+ img_dir=args.img_dir,
162
+ model_path=args.model_path,
163
+ max_keypoints=args.max_keypoints,
164
+ descriptor_dim=args.descriptor_dim,
165
+ num_images=args.num_images,
166
+ start_index=args.start_index,
167
+ num_loader_threads=args.num_loader_threads,
168
+ queue_size=args.queue_size,
169
+ image_size=(args.width, args.height),
170
+ )
171
+
172
+
173
+ if __name__ == "__main__":
174
+ parser = argparse.ArgumentParser(
175
+ description="Benchmark a TensorRT SuperPoint engine."
176
+ )
177
+ add_parser_args(parser)
178
+ main(parser.parse_args())
src/superpoint_pruning/evaluation/eval.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ from pathlib import Path
3
+
4
+ import torch
5
+ import numpy as np
6
+ from tqdm import tqdm
7
+
8
+ from superpoint_pruning.models.superpoint import SuperPoint
9
+ from superpoint_pruning.distillation.utils import (
10
+ load_grayscale_image,
11
+ rescale_image,
12
+ plot_images_with_keypoints,
13
+ )
14
+ from superpoint_pruning.evaluation.metrics import BenchmarkMetrics
15
+ from superpoint_pruning.paths import DEFAULT_IMAGE_DIR, DEFAULT_TRAINING_IDS
16
+ from superpoint_pruning.pruning import add_pruning_parser_args, pruning_config_from_args
17
+
18
+
19
+ def _image_path(image_dir: Path | str, sample: int) -> Path:
20
+ return Path(image_dir) / f"image_0_{sample}.png"
21
+
22
+
23
+ @torch.inference_mode()
24
+ def evaluate(
25
+ sp_original,
26
+ sp_pruned,
27
+ sp_head,
28
+ matcher,
29
+ metrics,
30
+ image_dir,
31
+ start_idx=1000,
32
+ end_idx=2000,
33
+ device="cpu",
34
+ save_preds=False,
35
+ trained=False,
36
+ skip=False,
37
+ image_size=(1024, 768),
38
+ training_ids=None,
39
+ hierarchical=False,
40
+ skip_refinement=False,
41
+ ):
42
+
43
+ try:
44
+ from lightglue import LightGlue
45
+ except ImportError as exc:
46
+ raise ImportError(
47
+ "The evaluation requires LightGlue for matching metrics"
48
+ ) from exc
49
+
50
+ if training_ids is not None:
51
+ with open(training_ids, "r") as f:
52
+ image_ids = f.read().splitlines()
53
+ image_ids = [int(id.split("_")[-1].split(".")[0]) for id in image_ids]
54
+
55
+ skipped = 0
56
+ sp_head_hier = sp_pruned.dense_head
57
+ image_dir = Path(image_dir)
58
+
59
+ for sample in tqdm(range(start_idx, end_idx)):
60
+ if skip and sample in image_ids:
61
+ skipped += 1
62
+ continue
63
+ img = load_grayscale_image(str(_image_path(image_dir, sample)))
64
+ img, scale = rescale_image(img, new_size=image_size)
65
+ scale = torch.as_tensor(scale, device=device, dtype=torch.float32)
66
+ img = img[None, None].astype(np.float32)
67
+ img = torch.from_numpy(img).to(device)
68
+
69
+ kpts_dense, desc_dense = sp_pruned(img)
70
+ original_kpts_dense, original_desc_dense = sp_original(img)
71
+ kpts, _, desc = sp_head_hier(original_kpts_dense, desc_dense)
72
+ kpts = (kpts.to(torch.float32) + 0.5) / scale - 0.5
73
+
74
+ img2 = load_grayscale_image(str(_image_path(image_dir, sample + 10)))
75
+ img2, scale = rescale_image(img2, new_size=image_size)
76
+ scale = torch.as_tensor(scale, device=device, dtype=torch.float32)
77
+ img2 = img2[None, None].astype(np.float32)
78
+ img2 = torch.from_numpy(img2).to(device)
79
+
80
+ kpts_dense2, desc_dense2 = sp_pruned(img2)
81
+ original_kpts_dense2, original_desc_dense2 = sp_original(img2)
82
+ kpts2, _, desc2 = sp_head_hier(original_kpts_dense2, desc_dense2)
83
+ kpts2 = (kpts2.to(torch.float32) + 0.5) / scale - 0.5
84
+
85
+ original_kpts, _, original_desc = sp_head(
86
+ original_kpts_dense, original_desc_dense
87
+ )
88
+ original_kpts = (original_kpts.to(torch.float32) + 0.5) / scale - 0.5
89
+ original_kpts2, _, original_desc2 = sp_head(
90
+ original_kpts_dense2, original_desc_dense2
91
+ )
92
+ original_kpts2 = (original_kpts2.to(torch.float32) + 0.5) / scale - 0.5
93
+ pruned_kpts, _, pruned_desc = sp_head_hier(kpts_dense, desc_dense)
94
+ pruned_kpts = (pruned_kpts.to(torch.float32) + 0.5) / scale - 0.5
95
+
96
+ pruned_kpts2, _, pruned_desc2 = sp_head_hier(kpts_dense2, desc_dense2)
97
+ pruned_kpts2 = (pruned_kpts2.to(torch.float32) + 0.5) / scale - 0.5
98
+ metrics.update_keypoints(
99
+ pruned_kpts.detach().cpu(), original_kpts.detach().cpu()
100
+ )
101
+
102
+ original_matches = matcher(
103
+ {
104
+ "image0": {
105
+ "keypoints": original_kpts,
106
+ "descriptors": original_desc,
107
+ "image_size": torch.tensor([[640.0, 480.0]], device=device),
108
+ },
109
+ "image1": {
110
+ "keypoints": original_kpts2,
111
+ "descriptors": original_desc2,
112
+ "image_size": torch.tensor([[640.0, 480.0]], device=device),
113
+ },
114
+ }
115
+ )["matches"][0]
116
+
117
+ matches = matcher(
118
+ {
119
+ "image0": {
120
+ "keypoints": kpts,
121
+ "descriptors": desc,
122
+ "image_size": torch.tensor([[640.0, 480.0]], device=device),
123
+ },
124
+ "image1": {
125
+ "keypoints": kpts2,
126
+ "descriptors": desc2,
127
+ "image_size": torch.tensor([[640.0, 480.0]], device=device),
128
+ },
129
+ }
130
+ )["matches"][0]
131
+
132
+ coord_matches = torch.cat(
133
+ (kpts[0][matches[:, 0]], kpts2[0][matches[:, 1]]), dim=1
134
+ )
135
+ coord_original_matches = torch.cat(
136
+ (
137
+ original_kpts[0][original_matches[:, 0]],
138
+ original_kpts2[0][original_matches[:, 1]],
139
+ ),
140
+ dim=1,
141
+ )
142
+ matches_pruned = matcher(
143
+ {
144
+ "image0": {
145
+ "keypoints": pruned_kpts,
146
+ "descriptors": pruned_desc,
147
+ "image_size": torch.tensor([[640.0, 480.0]], device=device),
148
+ },
149
+ "image1": {
150
+ "keypoints": pruned_kpts2,
151
+ "descriptors": pruned_desc2,
152
+ "image_size": torch.tensor([[640.0, 480.0]], device=device),
153
+ },
154
+ }
155
+ )["matches"][0]
156
+ metrics.update_matches(
157
+ coord_matches.detach().cpu(), coord_original_matches.detach().cpu()
158
+ )
159
+ if hasattr(metrics, "pruned_matches"):
160
+ metrics.pruned_matches.append(len(matches_pruned.detach().cpu()))
161
+ else:
162
+ metrics.pruned_matches = [len(matches_pruned.detach().cpu())]
163
+
164
+ metrics.print_metrics()
165
+ print(
166
+ f"Average number of pruned matches (pruned keypoints + pruned descriptors): {np.mean(np.array(metrics.pruned_matches))}"
167
+ )
168
+ print("\n")
169
+ print(f"Skipped {skipped} images")
170
+
171
+
172
+ @torch.inference_mode()
173
+ def evaluate_simple(
174
+ sp_original,
175
+ sp_pruned,
176
+ sp_head,
177
+ metrics,
178
+ image_dir,
179
+ start_idx=1000,
180
+ end_idx=2000,
181
+ device="cpu",
182
+ save_preds=False,
183
+ trained=False,
184
+ skip=False,
185
+ image_size=(1024, 768),
186
+ training_ids=None,
187
+ hierarchical=False,
188
+ skip_refinement=False,
189
+ ):
190
+
191
+ if training_ids is not None:
192
+ with open(training_ids, "r") as f:
193
+ image_ids = f.read().splitlines()
194
+ image_ids = [int(id.split("_")[-1].split(".")[0]) for id in image_ids]
195
+
196
+ skipped = 0
197
+ desc_l2s = []
198
+ sp_head_hier = sp_pruned.dense_head
199
+ image_dir = Path(image_dir)
200
+
201
+ for sample in tqdm(range(start_idx, end_idx)):
202
+ if skip and sample in image_ids:
203
+ skipped += 1
204
+ continue
205
+ img = load_grayscale_image(str(_image_path(image_dir, sample)))
206
+ img, scale = rescale_image(img, new_size=image_size)
207
+ scale = torch.as_tensor(scale, device=device, dtype=torch.float32)
208
+ img = img[None, None].astype(np.float32)
209
+ img = torch.from_numpy(img).to(device)
210
+
211
+ kpts_dense, desc_dense = sp_pruned(img)
212
+ original_kpts_dense, original_desc_dense = sp_original(img)
213
+ kpts, _, desc = sp_head_hier(original_kpts_dense, desc_dense)
214
+ kpts = (kpts.to(torch.float32) + 0.5) / scale - 0.5
215
+
216
+ original_kpts, _, original_desc = sp_head(
217
+ original_kpts_dense, original_desc_dense
218
+ )
219
+ original_kpts = (original_kpts.to(torch.float32) + 0.5) / scale - 0.5
220
+ pruned_kpts, _, pruned_desc = sp_head_hier(kpts_dense, desc_dense)
221
+ pruned_kpts = (pruned_kpts.to(torch.float32) + 0.5) / scale - 0.5
222
+
223
+ desc_l2 = torch.norm(desc - original_desc, p=2, dim=-1).mean()
224
+ desc_l2s.append(desc_l2.item())
225
+
226
+ metrics.update_keypoints(
227
+ pruned_kpts.detach().cpu(), original_kpts.detach().cpu()
228
+ )
229
+
230
+ metrics.print_metrics()
231
+ print(f"Average descriptor L2: {np.mean(desc_l2s):.6f}")
232
+ print(f"Skipped {skipped} images")
233
+
234
+
235
+ def add_parser_args(parser: argparse.ArgumentParser) -> None:
236
+ parser.add_argument("--image-dir", type=Path, default=DEFAULT_IMAGE_DIR)
237
+ parser.add_argument("--training-ids", type=Path, default=DEFAULT_TRAINING_IDS)
238
+ parser.add_argument("--start-idx", type=int, default=1000)
239
+ parser.add_argument("--end-idx", type=int, default=2000)
240
+ parser.add_argument("--num-keypoints", type=int, default=1024)
241
+ parser.add_argument("--width", type=int, default=640)
242
+ parser.add_argument("--height", type=int, default=480)
243
+ parser.add_argument("--skip", action=argparse.BooleanOptionalAction, default=True)
244
+ parser.add_argument(
245
+ "--hierarchical", action=argparse.BooleanOptionalAction, default=False
246
+ )
247
+ parser.add_argument(
248
+ "--skip-refinement", action=argparse.BooleanOptionalAction, default=False
249
+ )
250
+ add_pruning_parser_args(parser)
251
+
252
+
253
+ def main(args: argparse.Namespace) -> None:
254
+ device = "cuda" if torch.cuda.is_available() else "cpu"
255
+ pruning_config, pruning_checkpoint = pruning_config_from_args(args)
256
+ sp_original = (
257
+ SuperPoint(num_keypoints=args.num_keypoints, return_dense=True)
258
+ .eval()
259
+ .to(device)
260
+ )
261
+ sp_pruned = SuperPoint(
262
+ num_keypoints=args.num_keypoints,
263
+ return_dense=True,
264
+ hierarchical_topk=args.hierarchical,
265
+ skip_refinement=args.skip_refinement,
266
+ ).eval()
267
+ if pruning_config:
268
+ sp_pruned.prune_backbone(pruning_config)
269
+ if pruning_checkpoint is not None:
270
+ sp_pruned.load_pruned_weights(str(pruning_checkpoint))
271
+ sp_pruned = sp_pruned.to(device).eval()
272
+
273
+ evaluate_simple(
274
+ sp_original,
275
+ sp_pruned,
276
+ sp_original.dense_head,
277
+ BenchmarkMetrics(),
278
+ args.image_dir,
279
+ start_idx=args.start_idx,
280
+ end_idx=args.end_idx,
281
+ device=device,
282
+ skip=args.skip,
283
+ training_ids=args.training_ids,
284
+ image_size=(args.width, args.height),
285
+ hierarchical=args.hierarchical,
286
+ skip_refinement=args.skip_refinement,
287
+ )
288
+
289
+
290
+ if __name__ == "__main__":
291
+ parser = argparse.ArgumentParser(description="Evaluate a SuperPoint model.")
292
+ add_parser_args(parser)
293
+ main(parser.parse_args())
src/superpoint_pruning/evaluation/metrics.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import torch
3
+
4
+
5
+ def match_overlap(pred, gt):
6
+ pred = pred.detach().cpu() if torch.is_tensor(pred) else torch.as_tensor(pred)
7
+ gt = gt.detach().cpu() if torch.is_tensor(gt) else torch.as_tensor(gt)
8
+
9
+ if len(pred.shape) > 1 and pred.shape[1] == 3:
10
+ pred = pred[:, 1:]
11
+ if len(gt.shape) > 1 and gt.shape[1] == 3:
12
+ gt = gt[:, 1:]
13
+
14
+ if len(pred) > 0:
15
+ invalid = torch.nonzero(pred[:, 0] == -1, as_tuple=False)
16
+ if len(invalid) > 0:
17
+ pred = pred[: invalid[0].item()]
18
+
19
+ num_preds = len(pred)
20
+ if num_preds == 0 or len(gt) == 0:
21
+ running_recall = 0
22
+ else:
23
+ overlap = (pred[:, None, :] == gt[None, :, :]).all(dim=2)
24
+ running_recall = overlap.any(dim=1).sum().item()
25
+
26
+ recall = running_recall / len(gt) if len(gt) > 0 else 0
27
+ precision = running_recall / num_preds if num_preds > 0 else 0
28
+
29
+ # recall: # of original matches found
30
+ # precision: in this case, the # of correct predictions
31
+ return recall, precision, num_preds
32
+
33
+
34
+ def keypoint_overlap(pred, gt, cell_size=8, max_x=640, max_y=480):
35
+ if len(pred.shape) == 3:
36
+ pred = pred[0]
37
+ if len(gt.shape) == 3:
38
+ gt = gt[0]
39
+
40
+ num_cells_x = (max_x + cell_size - 1) // cell_size
41
+ num_cells_y = (max_y + cell_size - 1) // cell_size
42
+ num_cells = num_cells_x * num_cells_y
43
+
44
+ def counts_per_cell(kpts):
45
+ valid = (
46
+ (kpts[:, 0] >= 0)
47
+ & (kpts[:, 0] < max_x)
48
+ & (kpts[:, 1] >= 0)
49
+ & (kpts[:, 1] < max_y)
50
+ )
51
+ kpts = kpts[valid]
52
+ cell_x = torch.div(kpts[:, 0], cell_size, rounding_mode="floor").long()
53
+ cell_y = torch.div(kpts[:, 1], cell_size, rounding_mode="floor").long()
54
+ linear_idx = cell_y * num_cells_x + cell_x
55
+ return torch.bincount(linear_idx, minlength=num_cells)
56
+
57
+ pred_counts = counts_per_cell(pred)
58
+ gt_counts = counts_per_cell(gt)
59
+ covered = torch.minimum(pred_counts, gt_counts).sum().item()
60
+ return covered, pred_counts.sum().item(), gt_counts.sum().item()
61
+
62
+
63
+ class BenchmarkMetrics:
64
+ def __init__(self):
65
+ self.recalls = []
66
+ self.precisions = []
67
+ self.gt_counts = []
68
+ self.pred_counts = []
69
+ self.keypoints_covered = []
70
+ self.pred_counts_kpts = []
71
+ self.gt_counts_kpts = []
72
+
73
+ def update_matches(self, pred, gt):
74
+ recall, precision, num_preds = match_overlap(pred, gt)
75
+ self.recalls.append(recall)
76
+ self.precisions.append(precision)
77
+ self.gt_counts.append(len(gt))
78
+ self.pred_counts.append(num_preds)
79
+
80
+ def update_keypoints(self, pred, gt):
81
+ covered, pred_counts, gt_counts = keypoint_overlap(pred, gt)
82
+ self.keypoints_covered.append(covered)
83
+ self.pred_counts_kpts.append(pred_counts)
84
+ self.gt_counts_kpts.append(gt_counts)
85
+
86
+ def print_metrics(self):
87
+ print(f"Recall: {np.mean(self.recalls)}")
88
+ print(f"Precision: {np.mean(self.precisions)}")
89
+ print(
90
+ f"Average number of matches (original keypoints + pruned descriptors): {np.mean(np.array(self.pred_counts))}"
91
+ )
92
+ print(
93
+ f"Average number of matches (original keypoints + original descriptors): {np.mean(np.array(self.gt_counts))}"
94
+ )
95
+ print(
96
+ f"Average change in number of matches: {np.mean(np.array(self.pred_counts) - np.array(self.gt_counts))}"
97
+ )
98
+ print(
99
+ f"Average number of keypoints covered: {np.mean(np.array(self.keypoints_covered))}"
100
+ )
101
+ print(
102
+ f"Average number of keypoints in prediction: {np.mean(np.array(self.pred_counts_kpts))}"
103
+ )
104
+ print(
105
+ f"Average number of keypoints in ground truth: {np.mean(np.array(self.gt_counts_kpts))}"
106
+ )
src/superpoint_pruning/evaluation/onnx_helper.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #
2
+ # SPDX-FileCopyrightText: Copyright (c) 1993-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ import numpy as np
19
+ import tensorrt as trt
20
+
21
+ import pycuda.driver as cuda
22
+ import pycuda.autoinit
23
+
24
+
25
+ class WrapperBase:
26
+
27
+ def close(self):
28
+ pass
29
+
30
+
31
+ class SP_ONNXClassifierWrapper(WrapperBase):
32
+ def __init__(
33
+ self,
34
+ file,
35
+ target_dtype=np.float32,
36
+ batch_size=1,
37
+ max_keypoints=512,
38
+ descriptor_dim=256,
39
+ ):
40
+
41
+ self.target_dtype = target_dtype
42
+ self.load(file)
43
+
44
+ self.stream = None
45
+ self.max_keypoints = max_keypoints
46
+ self.batch_size = batch_size
47
+ self.descriptor_dim = descriptor_dim
48
+
49
+ def load(self, file):
50
+ f = open(file, "rb")
51
+ self.runtime = trt.Runtime(trt.Logger(trt.Logger.WARNING))
52
+
53
+ self.engine = self.runtime.deserialize_cuda_engine(f.read())
54
+ self.context = self.engine.create_execution_context()
55
+
56
+ def allocate_memory(self, batch):
57
+ self.keypoints = np.empty(
58
+ (self.batch_size, self.max_keypoints, 2), dtype=np.float32
59
+ )
60
+ self.scores = np.empty((self.batch_size, self.max_keypoints), dtype=np.float32)
61
+ self.descriptors = np.empty(
62
+ (self.batch_size, self.max_keypoints, self.descriptor_dim), dtype=np.float32
63
+ )
64
+
65
+ # Allocate device memory
66
+ self.d_input = cuda.mem_alloc(1 * batch.nbytes)
67
+ self.d_keypoints = cuda.mem_alloc(1 * self.keypoints.nbytes)
68
+ self.d_scores = cuda.mem_alloc(1 * self.scores.nbytes)
69
+ self.d_descriptors = cuda.mem_alloc(1 * self.descriptors.nbytes)
70
+
71
+ tensor_names = [
72
+ self.engine.get_tensor_name(i) for i in range(self.engine.num_io_tensors)
73
+ ]
74
+
75
+ self.context.set_tensor_address(tensor_names[0], int(self.d_input))
76
+ self.context.set_tensor_address(tensor_names[1], int(self.d_keypoints))
77
+ self.context.set_tensor_address(tensor_names[2], int(self.d_scores))
78
+ self.context.set_tensor_address(tensor_names[3], int(self.d_descriptors))
79
+
80
+ self.stream = cuda.Stream()
81
+
82
+ def predict(self, batch):
83
+ if self.stream is None:
84
+ self.allocate_memory(batch)
85
+
86
+ # Transfer input data to device
87
+ cuda.memcpy_htod_async(self.d_input, batch, self.stream)
88
+ # Execute model
89
+ self.context.execute_async_v3(self.stream.handle)
90
+ # Transfer predictions back
91
+ cuda.memcpy_dtoh_async(self.keypoints, self.d_keypoints, self.stream)
92
+ cuda.memcpy_dtoh_async(self.scores, self.d_scores, self.stream)
93
+ cuda.memcpy_dtoh_async(self.descriptors, self.d_descriptors, self.stream)
94
+ # Syncronize threads
95
+ self.stream.synchronize()
96
+
97
+ return self.keypoints, self.scores, self.descriptors
src/superpoint_pruning/evaluation/plot_keypoints.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ from pathlib import Path
3
+
4
+ import matplotlib.pyplot as plt
5
+ import numpy as np
6
+ import torch
7
+
8
+ from superpoint_pruning.distillation.utils import load_grayscale_image, rescale_image
9
+ from superpoint_pruning.models.superpoint import SuperPoint
10
+ from superpoint_pruning.paths import DEFAULT_IMAGE_DIR
11
+ from superpoint_pruning.pruning import add_pruning_parser_args, pruning_config_from_args
12
+
13
+ SHARED_COLOR = "lime"
14
+ ORIGINAL_ONLY_COLOR = "deepskyblue"
15
+ PRUNED_ONLY_COLOR = "orangered"
16
+
17
+
18
+ def resolve_image_path(image_dir: Path, image_name: Path) -> Path:
19
+ if image_name.is_absolute():
20
+ return image_name
21
+ return image_dir / image_name
22
+
23
+
24
+ def classify_keypoints(
25
+ original: torch.Tensor,
26
+ pruned: torch.Tensor,
27
+ image_width: int,
28
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
29
+ original = original.detach().cpu().round().to(torch.int64)
30
+ pruned = pruned.detach().cpu().round().to(torch.int64)
31
+ original_ids = original[:, 1] * image_width + original[:, 0]
32
+ pruned_ids = pruned[:, 1] * image_width + pruned[:, 0]
33
+
34
+ shared_mask = torch.isin(original_ids, pruned_ids)
35
+ original_only_mask = ~shared_mask
36
+ pruned_only_mask = ~torch.isin(pruned_ids, original_ids)
37
+
38
+ return (
39
+ original[shared_mask].numpy(),
40
+ original[original_only_mask].numpy(),
41
+ pruned[pruned_only_mask].numpy(),
42
+ )
43
+
44
+
45
+ @torch.inference_mode()
46
+ def extract_keypoints(
47
+ model: SuperPoint,
48
+ image: torch.Tensor,
49
+ scale: torch.Tensor,
50
+ ) -> torch.Tensor:
51
+ keypoints, _, _ = model(image)
52
+ keypoints = (keypoints.to(torch.float32) + 0.5) / scale - 0.5
53
+ return keypoints[0].detach().cpu()
54
+
55
+
56
+ def _scatter(ax, points: np.ndarray, color: str, label: str, size: int) -> None:
57
+ if len(points) == 0:
58
+ return
59
+ ax.scatter(points[:, 0], points[:, 1], s=size, marker=".", color=color, label=label)
60
+
61
+
62
+ def plot_side_by_side(
63
+ image: np.ndarray,
64
+ original: np.ndarray,
65
+ pruned: np.ndarray,
66
+ output: Path,
67
+ keypoint_size: int,
68
+ ) -> None:
69
+ fig, axes = plt.subplots(1, 2, figsize=(12, 5))
70
+ for ax, points, title in (
71
+ (axes[0], original, f"Original ({len(original)})"),
72
+ (axes[1], pruned, f"Pruned ({len(pruned)})"),
73
+ ):
74
+ ax.imshow(image, cmap="gray")
75
+ _scatter(ax, points, SHARED_COLOR, title, keypoint_size)
76
+ ax.set_title(title)
77
+ ax.axis("off")
78
+ fig.tight_layout()
79
+ output.parent.mkdir(parents=True, exist_ok=True)
80
+ fig.savefig(output, dpi=150, bbox_inches="tight")
81
+ plt.close(fig)
82
+
83
+
84
+ def plot_overlay(
85
+ image: np.ndarray,
86
+ original: torch.Tensor,
87
+ pruned: torch.Tensor,
88
+ output: Path,
89
+ keypoint_size: int,
90
+ ) -> None:
91
+ height, width = image.shape[:2]
92
+ shared, original_only, pruned_only = classify_keypoints(original, pruned, width)
93
+ fig, ax = plt.subplots(figsize=(8, 6))
94
+ ax.imshow(image, cmap="gray")
95
+ _scatter(ax, shared, SHARED_COLOR, f"Shared ({len(shared)})", keypoint_size)
96
+ _scatter(
97
+ ax,
98
+ original_only,
99
+ ORIGINAL_ONLY_COLOR,
100
+ f"Original only ({len(original_only)})",
101
+ keypoint_size,
102
+ )
103
+ _scatter(
104
+ ax,
105
+ pruned_only,
106
+ PRUNED_ONLY_COLOR,
107
+ f"Pruned only ({len(pruned_only)})",
108
+ keypoint_size,
109
+ )
110
+ ax.set_title("Shared vs unique keypoints")
111
+ ax.axis("off")
112
+ ax.legend(loc="upper right", framealpha=0.8)
113
+ fig.tight_layout()
114
+ output.parent.mkdir(parents=True, exist_ok=True)
115
+ fig.savefig(output, dpi=150, bbox_inches="tight")
116
+ plt.close(fig)
117
+
118
+
119
+ def add_parser_args(parser: argparse.ArgumentParser) -> None:
120
+ parser.add_argument("--image-dir", type=Path, default=DEFAULT_IMAGE_DIR)
121
+ parser.add_argument(
122
+ "--image-name",
123
+ type=Path,
124
+ required=True,
125
+ help="Image filename or absolute path.",
126
+ )
127
+ parser.add_argument("--output", type=Path, default=None, help="Output figure path.")
128
+ parser.add_argument(
129
+ "--overlay",
130
+ action=argparse.BooleanOptionalAction,
131
+ default=False,
132
+ help="Plot both models on one image: shared, original-only, and pruned-only keypoints.",
133
+ )
134
+ parser.add_argument("--num-keypoints", type=int, default=512)
135
+ parser.add_argument("--width", type=int, default=640)
136
+ parser.add_argument("--height", type=int, default=480)
137
+ parser.add_argument(
138
+ "--hierarchical", action=argparse.BooleanOptionalAction, default=False
139
+ )
140
+ parser.add_argument(
141
+ "--skip-refinement", action=argparse.BooleanOptionalAction, default=False
142
+ )
143
+ parser.add_argument("--keypoint-size", type=int, default=5)
144
+ add_pruning_parser_args(parser)
145
+
146
+
147
+ def main(args: argparse.Namespace) -> None:
148
+ image_path = resolve_image_path(args.image_dir, args.image_name)
149
+ output = args.output
150
+ if output is None:
151
+ suffix = "overlay" if args.overlay else "keypoints"
152
+ output = Path(f"{image_path.stem}_{suffix}.png")
153
+
154
+ device = "cuda" if torch.cuda.is_available() else "cpu"
155
+ pruning_config, pruning_checkpoint = pruning_config_from_args(args)
156
+
157
+ original_image = load_grayscale_image(str(image_path))
158
+ image, scale = rescale_image(original_image, new_size=(args.width, args.height))
159
+ scale = torch.as_tensor(scale, device=device, dtype=torch.float32)
160
+ batch = torch.from_numpy(image[None, None].astype(np.float32)).to(device)
161
+
162
+ sp_original = SuperPoint(num_keypoints=args.num_keypoints).eval().to(device)
163
+ sp_pruned = SuperPoint(
164
+ num_keypoints=args.num_keypoints,
165
+ hierarchical_topk=args.hierarchical,
166
+ skip_refinement=args.skip_refinement,
167
+ )
168
+ if pruning_config:
169
+ sp_pruned.prune_backbone(pruning_config)
170
+ if pruning_checkpoint is not None:
171
+ sp_pruned.load_pruned_weights(str(pruning_checkpoint))
172
+ sp_pruned = sp_pruned.to(device).eval()
173
+
174
+ original_kpts = extract_keypoints(sp_original, batch, scale)
175
+ pruned_kpts = extract_keypoints(sp_pruned, batch, scale)
176
+
177
+ if args.overlay:
178
+ plot_overlay(
179
+ original_image, original_kpts, pruned_kpts, output, args.keypoint_size
180
+ )
181
+ else:
182
+ plot_side_by_side(
183
+ original_image,
184
+ original_kpts.numpy(),
185
+ pruned_kpts.numpy(),
186
+ output,
187
+ args.keypoint_size,
188
+ )
189
+ print(f"Saved keypoint plot to {output.resolve()}")
190
+
191
+
192
+ if __name__ == "__main__":
193
+ parser = argparse.ArgumentParser(
194
+ description="Plot original and pruned SuperPoint keypoints."
195
+ )
196
+ add_parser_args(parser)
197
+ main(parser.parse_args())
src/superpoint_pruning/export.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export SuperPoint to ONNX.
2
+
3
+ Examples:
4
+ superpoint-pruning export --output superpoint.onnx
5
+ superpoint-pruning export --backbone_0_1 32 --backbone_1_0 48 --output pruned.onnx
6
+ superpoint-pruning export --pruning-config 32_48_64.ckpt --output pruned.onnx
7
+ python -m superpoint_pruning.export --output superpoint.onnx
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from pathlib import Path
14
+ import torch
15
+ from superpoint_pruning.models.superpoint import SuperPoint
16
+ from superpoint_pruning.pruning import add_pruning_parser_args, pruning_config_from_args
17
+
18
+
19
+ def validate_export_args(args: argparse.Namespace) -> None:
20
+ if args.width <= 0 or args.height <= 0 or args.batch_dim <= 0:
21
+ raise ValueError("Width, height, and batch_dim must be positive.")
22
+ if args.width % 8 or args.height % 8:
23
+ raise ValueError("Width and height must be divisible by 8.")
24
+ if args.num_keypoints <= 0 or args.num_keypoints > args.width * args.height:
25
+ raise ValueError("num_keypoints must be between 1 and width * height.")
26
+ if args.hierarchical_topk:
27
+ if args.hierarchical_tile_size <= 0:
28
+ raise ValueError("hierarchical_tile_size must be positive.")
29
+ if args.height % args.hierarchical_tile_size:
30
+ raise ValueError(
31
+ "hierarchical_tile_size must divide the input height when hierarchical_topk is enabled."
32
+ )
33
+ if args.num_keypoints > args.hierarchical_tile_size * args.width:
34
+ raise ValueError(
35
+ "num_keypoints must not exceed hierarchical_tile_size * width when hierarchical_topk is enabled."
36
+ )
37
+
38
+
39
+ def add_parser_args(parser: argparse.ArgumentParser) -> None:
40
+ parser.add_argument("--num-keypoints", type=int, default=512)
41
+ parser.add_argument(
42
+ "--skip-refinement",
43
+ action=argparse.BooleanOptionalAction,
44
+ default=False,
45
+ help="Skip the iterative NMS refinement.",
46
+ )
47
+ parser.add_argument(
48
+ "--hierarchical-topk",
49
+ action=argparse.BooleanOptionalAction,
50
+ default=False,
51
+ )
52
+ parser.add_argument("--hierarchical-tile-size", type=int, default=32)
53
+ parser.add_argument("--width", type=int, default=640)
54
+ parser.add_argument("--height", type=int, default=480)
55
+ parser.add_argument("--batch-dim", type=int, default=1)
56
+ parser.add_argument("--output", type=Path, default=Path("superpoint.onnx"))
57
+ add_pruning_parser_args(parser)
58
+ parser.add_argument(
59
+ "--skip-bn", action=argparse.BooleanOptionalAction, default=False
60
+ )
61
+
62
+
63
+ def build_parser() -> argparse.ArgumentParser:
64
+ parser = argparse.ArgumentParser(description="Export a SuperPoint model to ONNX.")
65
+ add_parser_args(parser)
66
+ return parser
67
+
68
+
69
+ def main(args: argparse.Namespace) -> None:
70
+ pruning_config, pruning_checkpoint = pruning_config_from_args(args)
71
+ validate_export_args(args)
72
+
73
+ print("num_keypoints: ", args.num_keypoints)
74
+ print("skip_refinement: ", args.skip_refinement)
75
+ print("hierarchical_topk: ", args.hierarchical_topk)
76
+ print("hierarchical_tile_size: ", args.hierarchical_tile_size)
77
+ print("width: ", args.width)
78
+ print("height: ", args.height)
79
+ print("batch_dim: ", args.batch_dim)
80
+ print("output: ", args.output)
81
+ print("pruning_config: ", args.pruning_config)
82
+ print("skip_bn: ", args.skip_bn)
83
+
84
+ model = SuperPoint(
85
+ num_keypoints=args.num_keypoints,
86
+ skip_refinement=args.skip_refinement,
87
+ hierarchical_topk=args.hierarchical_topk,
88
+ hierarchical_tile_size=args.hierarchical_tile_size,
89
+ use_bn=not args.skip_bn,
90
+ )
91
+ if pruning_config:
92
+ model.prune_backbone(pruning_config)
93
+ if pruning_checkpoint:
94
+ model.load_pruned_weights(str(pruning_checkpoint))
95
+
96
+ inputs = torch.zeros(args.batch_dim, 1, args.height, args.width)
97
+ model.eval()
98
+ args.output.parent.mkdir(parents=True, exist_ok=True)
99
+ with torch.no_grad():
100
+ torch.onnx.export(
101
+ model.cpu(),
102
+ inputs,
103
+ args.output,
104
+ input_names=["inputs"],
105
+ output_names=["keypoints", "scores", "descriptors"],
106
+ )
107
+ print(f"Exported {args.output} with input shape {tuple(inputs.shape)}.")
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main(build_parser().parse_args())
src/superpoint_pruning/models/__init__.py ADDED
File without changes
src/superpoint_pruning/models/superpoint.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PyTorch implementation of the SuperPoint model,
2
+ derived from the TensorFlow re-implementation (2018).
3
+ Authors: Rémi Pautrat, Paul-Edouard Sarlin
4
+ """
5
+
6
+ from types import SimpleNamespace
7
+
8
+ import torch
9
+ import torch.nn as nn
10
+
11
+ from superpoint_pruning.paths import DEFAULT_WEIGHTS_PATH
12
+
13
+ V6_PREFIXES = [
14
+ "backbone.0.0",
15
+ "backbone.0.1",
16
+ "backbone.1.0",
17
+ "backbone.1.1",
18
+ "backbone.2.0",
19
+ "backbone.2.1",
20
+ "backbone.3.0",
21
+ "backbone.3.1",
22
+ "detector.0",
23
+ "detector.1",
24
+ "descriptor.0",
25
+ "descriptor.1",
26
+ ]
27
+
28
+
29
+ def convert_v6_state_dict(state_dict: dict) -> dict:
30
+ converted = {}
31
+ for key, value in state_dict.items():
32
+ for prefix in V6_PREFIXES:
33
+ token = prefix + "."
34
+ if not key.startswith(token):
35
+ continue
36
+ kind, param = key[len(token) :].split(".", 1)
37
+ if kind == "conv":
38
+ converted[f"{prefix.replace('.', '_')}.{param}"] = value
39
+ elif kind == "bn":
40
+ converted[f"{prefix.replace('.', '_')}_bn.{param}"] = value
41
+ else:
42
+ raise KeyError(f"Unrecognized v6 submodule in '{key}'")
43
+ break
44
+ else:
45
+ raise KeyError(f"Unrecognized v6 key: {key}")
46
+ return converted
47
+
48
+
49
+ def sample_descriptors(keypoints, descriptors, s: int = 8):
50
+ b, c, h, w = descriptors.shape
51
+ divisor = (
52
+ torch._shape_as_tensor(descriptors)[[3, 2]]
53
+ .to(keypoints.dtype)
54
+ .to(keypoints.device)
55
+ * s
56
+ )
57
+ keypoints = (keypoints + 0.5) / divisor
58
+ keypoints = keypoints * 2 - 1
59
+ descriptors = torch.nn.functional.grid_sample(
60
+ descriptors, keypoints.view(b, 1, -1, 2), mode="bilinear", align_corners=False
61
+ )
62
+ descriptors = torch.nn.functional.normalize(
63
+ descriptors.reshape(b, c, -1), p=2, dim=1
64
+ ).permute(0, 2, 1)
65
+ return descriptors
66
+
67
+
68
+ def batched_nms(scores, nms_radius: int, skip_refinement: bool = False):
69
+ assert nms_radius >= 0
70
+
71
+ def max_pool(x):
72
+ return torch.nn.functional.max_pool2d(
73
+ x, kernel_size=nms_radius * 2 + 1, stride=1, padding=nms_radius
74
+ )
75
+
76
+ scores = scores[:, None]
77
+ zeros = torch.zeros_like(scores)
78
+ max_mask = scores == max_pool(scores)
79
+ if not skip_refinement:
80
+ for _ in range(2):
81
+ supp_mask = max_pool(max_mask.float()) > 0
82
+ supp_scores = torch.where(supp_mask, zeros, scores)
83
+ new_max_mask = supp_scores == max_pool(supp_scores)
84
+ max_mask = max_mask | (new_max_mask & (~supp_mask))
85
+ return torch.where(max_mask, scores, zeros)[:, 0]
86
+
87
+
88
+ def hierarchical_topk(scores, tile_size: int, num_keypoints: int):
89
+ B, H, W = scores.shape
90
+ tile_h = tile_size
91
+ assert H % tile_h == 0, "Tile size must divide the height of the scores"
92
+ scores_tiled = scores.reshape(B, H // tile_h, tile_h * W)
93
+
94
+ local_scores, local_idx = scores_tiled.topk(num_keypoints, dim=-1, sorted=False)
95
+
96
+ # Convert local indices into global flattened indices.
97
+ tile_offset = (
98
+ torch.arange(H // tile_h, device=scores.device).view(1, -1, 1) * tile_h * W
99
+ )
100
+ global_idx = local_idx + tile_offset
101
+
102
+ local_scores = local_scores.reshape(B, -1)
103
+ global_idx = global_idx.reshape(B, -1)
104
+
105
+ top_scores, sel = local_scores.topk(num_keypoints, dim=-1, sorted=True)
106
+ top_indices = global_idx.gather(1, sel)
107
+
108
+ return top_scores, top_indices
109
+
110
+
111
+ def get_conv_layer(c_in, c_out, kernel_size, relu=True):
112
+ padding = (kernel_size - 1) // 2
113
+ conv = nn.Conv2d(c_in, c_out, kernel_size=kernel_size, stride=1, padding=padding)
114
+ return conv
115
+
116
+
117
+ class SuperPoint(nn.Module):
118
+ default_conf = {
119
+ "nms_radius": 4,
120
+ "num_keypoints": 1024,
121
+ "remove_borders": 4,
122
+ "descriptor_dim": 256,
123
+ "channels": [64, 64, 128, 128, 256],
124
+ "skip_refinement": False,
125
+ "hierarchical_topk": False,
126
+ "hierarchical_tile_size": 32,
127
+ "use_bn": True,
128
+ "default_weights_path": DEFAULT_WEIGHTS_PATH,
129
+ "load_default_weights": True,
130
+ "return_dense": False,
131
+ }
132
+
133
+ def __init__(self, **conf):
134
+ super().__init__()
135
+ conf = {**self.default_conf, **conf}
136
+ self.conf = SimpleNamespace(**conf)
137
+ self.stride = 2 ** (len(self.conf.channels) - 2)
138
+ channels = [1, *self.conf.channels[:-1]]
139
+ self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
140
+ self.relu = nn.ReLU(inplace=True)
141
+
142
+ for i, c in enumerate(channels[1:]):
143
+ self.add_module(f"backbone_{i}_0", get_conv_layer(channels[i], c, 3))
144
+ if self.conf.use_bn:
145
+ self.add_module(f"backbone_{i}_0_bn", nn.BatchNorm2d(c, eps=0.001))
146
+ self.add_module(f"backbone_{i}_1", get_conv_layer(c, c, 3))
147
+ if self.conf.use_bn:
148
+ self.add_module(f"backbone_{i}_1_bn", nn.BatchNorm2d(c, eps=0.001))
149
+
150
+ c = self.conf.channels[-1]
151
+ self.add_module("detector_0", get_conv_layer(channels[-1], c, 3))
152
+ if self.conf.use_bn:
153
+ self.add_module("detector_0_bn", nn.BatchNorm2d(c, eps=0.001))
154
+ self.add_module("detector_1", get_conv_layer(c, self.stride**2 + 1, 1))
155
+ if self.conf.use_bn:
156
+ self.add_module(
157
+ "detector_1_bn", nn.BatchNorm2d(self.stride**2 + 1, eps=0.001)
158
+ )
159
+ self.add_module("descriptor_0", get_conv_layer(channels[-1], c, 3))
160
+ if self.conf.use_bn:
161
+ self.add_module("descriptor_0_bn", nn.BatchNorm2d(c, eps=0.001))
162
+ self.add_module("descriptor_1", get_conv_layer(c, self.conf.descriptor_dim, 1))
163
+ if self.conf.use_bn:
164
+ self.add_module(
165
+ "descriptor_1_bn", nn.BatchNorm2d(self.conf.descriptor_dim, eps=0.001)
166
+ )
167
+
168
+ if self.conf.use_bn and self.conf.load_default_weights:
169
+ self.load_default_weights(self.conf.default_weights_path)
170
+
171
+ def _forward_conv(
172
+ self, name: str, x: torch.Tensor, relu: bool = True
173
+ ) -> torch.Tensor:
174
+ x = getattr(self, name)(x)
175
+ if relu:
176
+ x = self.relu(x)
177
+ if self.conf.use_bn:
178
+ x = getattr(self, name + "_bn")(x)
179
+ return x
180
+
181
+ def load_default_weights(self, weights_path: str) -> None:
182
+ """Load and rename superpoint_v6_from_tf.pth weights."""
183
+ checkpoint = torch.load(weights_path, map_location="cpu")
184
+ state_dict = convert_v6_state_dict(checkpoint)
185
+ missing, unexpected = self.load_state_dict(state_dict, strict=False)
186
+ missing = [k for k in missing if not k.endswith("num_batches_tracked")]
187
+ if missing or unexpected:
188
+ raise RuntimeError(
189
+ f"Failed to load weights from {weights_path}. missing={missing} unexpected={unexpected}"
190
+ )
191
+ print(f"Loaded default weights from {weights_path}")
192
+
193
+ def load_pruned_weights(self, checkpoint_path: str) -> None:
194
+ """Load pruned SP weights from checkpoint"""
195
+ checkpoint = torch.load(checkpoint_path, map_location="cpu")["state_dict"]
196
+ checkpoint = {k.replace("model.", ""): v for k, v in checkpoint.items()}
197
+ self.load_state_dict(checkpoint, strict=True)
198
+
199
+ def _bn_name(self, conv_name: str) -> str:
200
+ return conv_name + "_bn"
201
+
202
+ def _previous_backbone_layer(self, layer: str) -> str:
203
+ parts = layer.split("_")
204
+ stage, index = int(parts[1]), int(parts[2])
205
+ if index == 1:
206
+ return f"backbone_{stage}_0"
207
+ if index == 0 and stage > 0:
208
+ return f"backbone_{stage - 1}_1"
209
+ raise ValueError(f"Invalid layer: {layer}")
210
+
211
+ def _prune_bn(self, conv_name: str, keep_idx: torch.Tensor) -> None:
212
+ """Resize the BN that follows a pruned conv so channel counts still match."""
213
+ bn_name = self._bn_name(conv_name)
214
+ old_bn = getattr(self, bn_name)
215
+ new_bn = nn.BatchNorm2d(
216
+ len(keep_idx),
217
+ eps=old_bn.eps,
218
+ momentum=old_bn.momentum,
219
+ affine=old_bn.affine,
220
+ track_running_stats=old_bn.track_running_stats,
221
+ )
222
+ with torch.no_grad():
223
+ if old_bn.affine:
224
+ new_bn.weight.copy_(old_bn.weight[keep_idx])
225
+ new_bn.bias.copy_(old_bn.bias[keep_idx])
226
+ if old_bn.track_running_stats:
227
+ new_bn.running_mean.copy_(old_bn.running_mean[keep_idx])
228
+ new_bn.running_var.copy_(old_bn.running_var[keep_idx])
229
+ new_bn.num_batches_tracked.copy_(old_bn.num_batches_tracked)
230
+ setattr(self, bn_name, new_bn)
231
+
232
+ def prune_backbone(self, config: dict):
233
+ for layer, channel in config.items():
234
+ previous_layer = self._previous_backbone_layer(layer)
235
+ old1 = getattr(self, previous_layer)
236
+ old2 = getattr(self, layer)
237
+ magnitude = old2.weight.abs().mean(dim=(0, 2, 3))
238
+ keep_idx = (
239
+ torch.topk(magnitude, k=channel, largest=True).indices.sort().values
240
+ )
241
+ new1 = torch.nn.Conv2d(
242
+ old1.in_channels,
243
+ channel,
244
+ kernel_size=old1.kernel_size,
245
+ stride=1,
246
+ padding=1,
247
+ )
248
+ new2 = torch.nn.Conv2d(
249
+ channel,
250
+ old2.out_channels,
251
+ kernel_size=old2.kernel_size,
252
+ stride=1,
253
+ padding=1,
254
+ )
255
+ with torch.no_grad():
256
+ new1.weight.copy_(old1.weight[keep_idx, :, :, :])
257
+ new1.bias.copy_(old1.bias[keep_idx])
258
+ new2.weight.copy_(old2.weight[:, keep_idx, :, :])
259
+ new2.bias.copy_(old2.bias)
260
+ setattr(self, previous_layer, new1)
261
+ setattr(self, layer, new2)
262
+ if self.conf.use_bn:
263
+ self._prune_bn(previous_layer, keep_idx)
264
+
265
+ print("Pruned model structure:")
266
+ print(self)
267
+
268
+ def dense_head(
269
+ self, scores: torch.Tensor, descriptors_dense: torch.Tensor
270
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
271
+
272
+ descriptors_dense = torch.nn.functional.normalize(descriptors_dense, p=2, dim=1)
273
+
274
+ scores = torch.nn.functional.softmax(scores, 1)[:, :-1]
275
+ b, _, h, w = scores.shape
276
+ scores = scores.permute(0, 2, 3, 1).reshape(b, h, w, self.stride, self.stride)
277
+ scores = scores.permute(0, 1, 3, 2, 4).reshape(
278
+ b, h * self.stride, w * self.stride
279
+ )
280
+ scores = batched_nms(
281
+ scores, self.conf.nms_radius, skip_refinement=self.conf.skip_refinement
282
+ )
283
+
284
+ # Discard keypoints near the image borders
285
+ if self.conf.remove_borders:
286
+ pad = self.conf.remove_borders
287
+ scores[:, :pad] = -1
288
+ scores[:, :, :pad] = -1
289
+ scores[:, -pad:] = -1
290
+ scores[:, :, -pad:] = -1
291
+
292
+ if self.conf.hierarchical_topk:
293
+ top_scores, top_indices = hierarchical_topk(
294
+ scores, self.conf.hierarchical_tile_size, self.conf.num_keypoints
295
+ )
296
+ else:
297
+ top_scores, top_indices = scores.reshape(
298
+ b, h * self.stride * w * self.stride
299
+ ).topk(self.conf.num_keypoints)
300
+ y_idx = torch.div(top_indices, w * self.stride, rounding_mode="floor")
301
+ x_idx = torch.remainder(top_indices, w * self.stride)
302
+ top_keypoints = torch.stack((x_idx, y_idx), dim=-1).to(dtype=torch.float32)
303
+ top_descriptors = sample_descriptors(
304
+ top_keypoints, descriptors_dense, self.stride
305
+ )
306
+
307
+ return (
308
+ top_keypoints,
309
+ top_scores,
310
+ top_descriptors,
311
+ )
312
+
313
+ def forward(
314
+ self, image: torch.Tensor
315
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
316
+
317
+ x = self._forward_conv("backbone_0_0", image)
318
+ x = self._forward_conv("backbone_0_1", x)
319
+ x = self.pool(x)
320
+ x = self._forward_conv("backbone_1_0", x)
321
+ x = self._forward_conv("backbone_1_1", x)
322
+ x = self.pool(x)
323
+ x = self._forward_conv("backbone_2_0", x)
324
+ x = self._forward_conv("backbone_2_1", x)
325
+ x = self.pool(x)
326
+ x = self._forward_conv("backbone_3_0", x)
327
+ x = self._forward_conv("backbone_3_1", x)
328
+ descriptors_dense = self._forward_conv(
329
+ "descriptor_1", self._forward_conv("descriptor_0", x), relu=False
330
+ )
331
+ scores = self._forward_conv(
332
+ "detector_1", self._forward_conv("detector_0", x), relu=False
333
+ )
334
+
335
+ if self.conf.return_dense:
336
+ return scores, descriptors_dense
337
+
338
+ return self.dense_head(scores, descriptors_dense)
339
+
340
+
341
+ if __name__ == "__main__":
342
+ sp = SuperPoint(num_keypoints=512)
343
+ inputs = torch.zeros(1, 1, 768, 1024)
344
+ torch.onnx.export(
345
+ sp.cpu(),
346
+ inputs,
347
+ f"SP.onnx",
348
+ input_names=["inputs"],
349
+ output_names=["keypoints", "scores", "descriptors"],
350
+ # opset_version=opset,
351
+ # dynamic_axes=dynamic_axes,
352
+ # dynamic_shapes=dynamic_shapes,
353
+ # dynamo=True,
354
+ )
src/superpoint_pruning/paths.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ PACKAGE_ROOT = Path(__file__).resolve().parent
4
+ DATA_ROOT = PACKAGE_ROOT / "distillation" / "data"
5
+ DEFAULT_CONFIG_PATH = PACKAGE_ROOT / "distillation" / "base_config.yaml"
6
+ DEFAULT_WEIGHTS_PATH = PACKAGE_ROOT / "weights" / "superpoint_v6_from_tf.pth"
7
+ DEFAULT_DATASET_NAME = "indoor_forward_3_snapdragon_with_gt"
8
+ DEFAULT_IMAGE_DIR = DATA_ROOT / "datasets" / DEFAULT_DATASET_NAME / "img"
9
+ DEFAULT_TRAINING_IDS = DATA_ROOT / "gt_id_file_train_base_250.txt"
src/superpoint_pruning/pruning.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from superpoint_pruning.models.superpoint import SuperPoint
7
+
8
+ PRUNABLE_LAYERS = (
9
+ "backbone_0_1",
10
+ "backbone_1_0",
11
+ "backbone_1_1",
12
+ "backbone_2_0",
13
+ "backbone_2_1",
14
+ "backbone_3_0",
15
+ "backbone_3_1",
16
+ )
17
+ _C = SuperPoint.default_conf["channels"]
18
+ MAX_PRUNED_CHANNELS = {
19
+ "backbone_0_1": _C[0],
20
+ "backbone_1_0": _C[0],
21
+ "backbone_1_1": _C[1],
22
+ "backbone_2_0": _C[1],
23
+ "backbone_2_1": _C[2],
24
+ "backbone_3_0": _C[2],
25
+ "backbone_3_1": _C[3],
26
+ }
27
+
28
+
29
+ def parse_pruning_config(checkpoint_path: str) -> dict[str, int]:
30
+ """Build a pruning config from a checkpoint named ``a_b_c...ckpt``."""
31
+ checkpoint = Path(checkpoint_path)
32
+ if checkpoint.suffix != ".ckpt":
33
+ raise ValueError("A pruning checkpoint must have a .ckpt extension.")
34
+
35
+ values = checkpoint.stem.split("_")
36
+ if not 1 <= len(values) <= len(PRUNABLE_LAYERS):
37
+ raise ValueError(
38
+ f"Expected 1-{len(PRUNABLE_LAYERS)} underscore-separated channel counts in '{checkpoint.name}'."
39
+ )
40
+
41
+ try:
42
+ config = {layer: int(value) for layer, value in zip(PRUNABLE_LAYERS, values)}
43
+ except ValueError as error:
44
+ raise ValueError(
45
+ "Pruning checkpoint names must contain only integer channel counts, for example '32_48_64.ckpt'."
46
+ ) from error
47
+
48
+ if any(channels <= 0 for channels in config.values()):
49
+ raise ValueError("Pruned channel counts must be positive.")
50
+ return config
51
+
52
+
53
+ def validate_pruning_config(pruning_config: dict[str, int]) -> None:
54
+ for layer, channels in pruning_config.items():
55
+ max_channels = MAX_PRUNED_CHANNELS[layer]
56
+ if channels > max_channels:
57
+ raise ValueError(
58
+ f"{layer} cannot be pruned to {channels} channels; it has only {max_channels} input channels."
59
+ )
60
+
61
+
62
+ def add_pruning_parser_args(parser: argparse.ArgumentParser) -> None:
63
+ parser.add_argument(
64
+ "--pruning-config",
65
+ type=Path,
66
+ help=(
67
+ "Checkpoint path with pruned weights. Its basename must be named like '32_48_64.ckpt'; "
68
+ "counts map to backbone_0_1, backbone_1_0, backbone_1_1, and so on."
69
+ ),
70
+ )
71
+ for layer in PRUNABLE_LAYERS:
72
+ parser.add_argument(
73
+ f"--{layer}",
74
+ dest=layer,
75
+ type=int,
76
+ metavar="CHANNELS",
77
+ help=f"Prune the input channels of {layer}.",
78
+ )
79
+
80
+
81
+ def pruning_config_from_args(
82
+ args: argparse.Namespace,
83
+ ) -> tuple[dict[str, int], Path | None]:
84
+ cli_pruning_config = {
85
+ layer: getattr(args, layer)
86
+ for layer in PRUNABLE_LAYERS
87
+ if getattr(args, layer) is not None
88
+ }
89
+ if args.pruning_config and cli_pruning_config:
90
+ raise ValueError(
91
+ "Use either --pruning-config or individual layer options, not both."
92
+ )
93
+
94
+ pruning_config = (
95
+ parse_pruning_config(str(args.pruning_config))
96
+ if args.pruning_config
97
+ else cli_pruning_config
98
+ )
99
+ validate_pruning_config(pruning_config)
100
+ return pruning_config, args.pruning_config
src/superpoint_pruning/weights/16_16_24_32_64.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fb893fb8121e2881d41132510ed6ff1708004f1c78a73f3dcf4fa2b237e7ea2d
3
+ size 12994600
src/superpoint_pruning/weights/superpoint_v6_from_tf.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cd5d19a5061848e248c17728878ea166b66512076d43c77dbcf27f4a88a56084
3
+ size 5251225
static/model_structure.png ADDED

Git LFS Details

  • SHA256: 203c5eadf673d740b7631737a26fb73d04145afb5e3cfe143c3cbdf0aee4007c
  • Pointer size: 131 Bytes
  • Size of remote file: 123 kB
static/pruned_indoor.png ADDED

Git LFS Details

  • SHA256: d4514b8989a30658c66bcd668925342654f71942de27f9a20529e0b1764a5fc3
  • Pointer size: 131 Bytes
  • Size of remote file: 638 kB
static/pruned_indoor_separate.png ADDED

Git LFS Details

  • SHA256: fcf81d2acba9e8d8f52cd19fbc290d752ef706ccc85806bc746d692ba3676389
  • Pointer size: 131 Bytes
  • Size of remote file: 592 kB
static/pruned_light_indoor.png ADDED

Git LFS Details

  • SHA256: 0528fdbb48af7e2c48b69dd6d2f82e27ea789c911e2bec069a1f8d4a5f0c033a
  • Pointer size: 131 Bytes
  • Size of remote file: 652 kB
static/pruned_light_outdoor.png ADDED

Git LFS Details

  • SHA256: 4ee74e08635250910f2b432543555396d01ad3bd22a997e3a1b35d7038dfeaa9
  • Pointer size: 131 Bytes
  • Size of remote file: 637 kB
static/pruned_noref_indoor.png ADDED

Git LFS Details

  • SHA256: e59681ee29e43b9715fbbea447843380625111f531d92ede42e82b3ac5b458d6
  • Pointer size: 131 Bytes
  • Size of remote file: 645 kB
static/pruned_noref_outdoor.png ADDED

Git LFS Details

  • SHA256: c09d3258b2c798e729b4038c58d2e83eba30eb11a51af1def18278322be02a8d
  • Pointer size: 131 Bytes
  • Size of remote file: 617 kB
static/pruned_outdoor.png ADDED

Git LFS Details

  • SHA256: 0d44126a51c5b27b7d0420e229dd074609fc4247a1cc9f1834da2cb969facb75
  • Pointer size: 131 Bytes
  • Size of remote file: 613 kB