.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Visual Place Recognition: A Tutorial
2 | Work in progress: This repository provides the example code from our paper "Visual Place Recognition: A Tutorial".
3 | The code performs VPR on the GardensPoint day_right--night_right dataset. Output is a plotted pr-curve, matching decisions, two examples for a true-positive and a false-positive matching, and the AUC performance, as shown below.
4 |
5 | If you use our work for your academic research, please refer to the following paper:
6 | ```bibtex
7 | @article{SchubertRAM2024ICRA2024,
8 | title={Visual Place Recognition: A Tutorial},
9 | author={Schubert, Stefan and Neubert, Peer and Garg, Sourav and Milford, Michael and Fischer, Tobias},
10 | journal={IEEE Robotics \& Automation Magazine},
11 | year={2024},
12 | doi={10.1109/MRA.2023.3310859}
13 | }
14 | ```
15 |
16 |
17 | ## How to run the code
18 | ### Online (Using GitHub Codespaces)
19 |
20 | This repository is configured for use with [GitHub Codespaces](https://github.com/features/codespaces), a service that provides you with a fully-configured Visual Studio Code environment in the cloud, directly from your GitHub repository.
21 |
22 | To open this repository in a Codespace:
23 |
24 | 1. Click on the green "Code" button near the top-right corner of the repository page.
25 | 2. In the dropdown, select "Open with Codespaces", and then click on "+ New codespace".
26 | 3. Your Codespace will be created and will start automatically. This process may take a few minutes.
27 |
28 | Once your Codespace is ready, it will open in a new browser tab. This is a full-featured version of VS Code running in your browser, and it has access to all the files in your repository and all the tools installed in your Docker container.
29 |
30 | You can run commands in the terminal, edit files, debug code, commit changes, and do anything else you would normally do in VS Code. When you're done, you can close the browser tab, and your Codespace will automatically stop after a period of inactivity.
31 |
32 | ### Local
33 | ```
34 | python3 demo.py
35 | ```
36 | The GardensPoints Walking dataset will be downloaded automatically. You should get an output similar to this:
37 | ```
38 | python3 demo.py
39 | ===== Load dataset
40 | ===== Load dataset GardensPoint day_right--night_right
41 | ===== Compute local DELF descriptors
42 | ===== Compute holistic HDC-DELF descriptors
43 | ===== Compute cosine similarities S
44 | ===== Match images
45 | ===== Evaluation
46 |
47 | ===== AUC (area under curve): 0.74
48 | ===== R@100P (maximum recall at 100% precision): 0.36
49 | ===== recall@K (R@K) -- R@1: 0.85 , R@5: 0.925 , R@10: 0.945
50 | ```
51 |
52 | | Precision-recall curve | Matchings M | Examples for a true positive and a false positive |
53 | |:-------------------------:|:-------------------------:|:-------------------------:|
54 | |
|
|
|
55 |
56 | ## Requirements
57 | The code was tested with the library versions listed in [requirements.txt](./requirements.txt). Note that Tensorflow or PyTorch is only required if the corresponding image descriptor is used. If you use pip, simply:
58 | ```bash
59 | pip install -r requirements.txt
60 | ```
61 |
62 | You can create a conda environment containing these libraries as follows (or use the provided [environment.yml](./.devcontainer/environment.yml)):
63 | ```bash
64 | mamba create -n vprtutorial python numpy pytorch torchvision natsort tqdm opencv pillow scikit-learn faiss matplotlib-base tensorflow tensorflow-hub tqdm scikit-image patchnetvlad -c conda-forge
65 | ```
66 |
67 |
68 | ## List of existing open-source implementations for VPR (work in progress)
69 | [//]: # (use or rowspan to combine cells)
70 |
71 |
72 | ### Descriptors
73 | #### Holistic descriptors
74 |
123 |
124 |
125 | #### Local descriptors
126 |
160 |
161 | #### Local descriptor aggregation
162 |
186 |
187 | ### Sequence methods
188 |
237 |
238 | ### Misc
239 |
240 |
241 |
242 | ICM |
243 | code |
244 | paper |
245 | Graph optimization of the similarity matrix S |
246 |
247 |
248 | SuperGlue |
249 | code |
250 | paper |
251 | Local descriptor matching |
252 |
253 |
254 |
255 |
256 | *Third party code. Not provided by the authors. Code implements the author's idea or can be used to implement the authors idea.
257 |
258 | ## Soft Ground Truth for evaluation
259 | In the evaluation metrics, a soft ground truth matrix can be used to ignore images with a very small visual overlap to avoid penalization in recall and precision analysis (see Equation 6 in [our paper](https://ieeexplore.ieee.org/document/10261441)). This currently is only supported for `matching="multi"`. For use in single matching, please use a dilated hard ground truth directly.
260 |
--------------------------------------------------------------------------------
/datasets/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stschubert/VPR_Tutorial/e071309301f7b360449700371a69242ed65cad80/datasets/__init__.py
--------------------------------------------------------------------------------
/datasets/load_dataset.py:
--------------------------------------------------------------------------------
1 | # =====================================================================
2 | # Copyright (C) 2023 Stefan Schubert, stefan.schubert@etit.tu-chemnitz.de
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU General Public License
15 | # along with this program. If not, see .
16 | # =====================================================================
17 | #
18 | import os
19 | import urllib.request
20 | import zipfile
21 | from glob import glob
22 | from PIL import Image
23 | import numpy as np
24 | from scipy.signal import convolve2d
25 | from typing import List, Tuple
26 | from abc import ABC, abstractmethod
27 |
28 |
29 | class Dataset(ABC):
30 | @abstractmethod
31 | def load(self) -> Tuple[List[np.ndarray], List[np.ndarray], np.ndarray, np.ndarray]:
32 | pass
33 |
34 | @abstractmethod
35 | def download(self, destination: str):
36 | pass
37 |
38 |
39 | class GardensPointDataset(Dataset):
40 | def __init__(self, destination: str = 'images/GardensPoint/'):
41 | self.destination = destination
42 |
43 | def load(self) -> Tuple[List[np.ndarray], List[np.ndarray], np.ndarray, np.ndarray]:
44 | print('===== Load dataset GardensPoint day_right--night_right')
45 |
46 | # download images if necessary
47 | if not os.path.exists(self.destination):
48 | self.download(self.destination)
49 |
50 | # load images
51 | fns_db = sorted(glob(self.destination + 'day_right/*.jpg'))
52 | fns_q = sorted(glob(self.destination + 'night_right/*.jpg'))
53 |
54 | imgs_db = [np.array(Image.open(fn)) for fn in fns_db]
55 | imgs_q = [np.array(Image.open(fn)) for fn in fns_q]
56 |
57 | # create ground truth
58 | GThard = np.eye(len(imgs_db)).astype('bool')
59 | GTsoft = convolve2d(GThard.astype('int'),
60 | np.ones((17, 1), 'int'), mode='same').astype('bool')
61 |
62 | return imgs_db, imgs_q, GThard, GTsoft
63 |
64 | def download(self, destination: str):
65 | print('===== GardensPoint dataset does not exist. Download to ' + destination + '...')
66 |
67 | fn = 'GardensPoint_Walking.zip'
68 | url = 'https://www.tu-chemnitz.de/etit/proaut/datasets/' + fn
69 |
70 | # create folders
71 | path = os.path.expanduser(destination)
72 | os.makedirs(path, exist_ok=True)
73 |
74 | # download
75 | urllib.request.urlretrieve(url, path + fn)
76 |
77 | # unzip
78 | with zipfile.ZipFile(path + fn, 'r') as zip_ref:
79 | zip_ref.extractall(destination)
80 |
81 | # remove zipfile
82 | os.remove(destination + fn)
83 |
84 |
85 | class StLuciaDataset(Dataset):
86 | def __init__(self, destination: str = 'images/StLucia_small/'):
87 | self.destination = destination
88 |
89 | def load(self) -> Tuple[List[np.ndarray], List[np.ndarray], np.ndarray, np.ndarray]:
90 | print('===== Load dataset StLucia 100909_0845--180809_1545 (small version)')
91 |
92 | # download images if necessary
93 | if not os.path.exists(self.destination):
94 | self.download(self.destination)
95 |
96 | # load images
97 | fns_db = sorted(glob(self.destination + '100909_0845/*.jpg'))
98 | fns_q = sorted(glob(self.destination + '180809_1545/*.jpg'))
99 |
100 | imgs_db = [np.array(Image.open(fn)) for fn in fns_db]
101 | imgs_q = [np.array(Image.open(fn)) for fn in fns_q]
102 |
103 | # create ground truth
104 | gt_data = np.load(self.destination + 'GT.npz')
105 | GThard = gt_data['GThard'].astype('bool')
106 | GTsoft = gt_data['GTsoft'].astype('bool')
107 |
108 | return imgs_db, imgs_q, GThard, GTsoft
109 |
110 | def download(self, destination: str):
111 | print('===== StLucia dataset does not exist. Download to ' + destination + '...')
112 |
113 | fn = 'StLucia_small.zip'
114 | url = 'https://www.tu-chemnitz.de/etit/proaut/datasets/' + fn
115 |
116 | # create folders
117 | path = os.path.expanduser(destination)
118 | os.makedirs(path, exist_ok=True)
119 |
120 | # download
121 | urllib.request.urlretrieve(url, path + fn)
122 |
123 | # unzip
124 | with zipfile.ZipFile(path + fn, 'r') as zip_ref:
125 | zip_ref.extractall(destination)
126 |
127 | # remove zipfile
128 | os.remove(destination + fn)
129 |
130 |
131 | class SFUDataset(Dataset):
132 | def __init__(self, destination: str = 'images/SFU/'):
133 | self.destination = destination
134 |
135 | def load(self) -> Tuple[List[np.ndarray], List[np.ndarray], np.ndarray, np.ndarray]:
136 | print('===== Load dataset SFU dry--jan')
137 |
138 | # download images if necessary
139 | if not os.path.exists(self.destination):
140 | self.download(self.destination)
141 |
142 | # load images
143 | fns_db = sorted(glob(self.destination + 'dry/*.jpg'))
144 | fns_q = sorted(glob(self.destination + 'jan/*.jpg'))
145 |
146 | imgs_db = [np.array(Image.open(fn)) for fn in fns_db]
147 | imgs_q = [np.array(Image.open(fn)) for fn in fns_q]
148 |
149 | # create ground truth
150 | gt_data = np.load(self.destination + 'GT.npz')
151 | GThard = gt_data['GThard'].astype('bool')
152 | GTsoft = gt_data['GTsoft'].astype('bool')
153 |
154 | return imgs_db, imgs_q, GThard, GTsoft
155 |
156 | def download(self, destination: str):
157 | print('===== SFU dataset does not exist. Download to ' + destination + '...')
158 |
159 | fn = 'SFU.zip'
160 | url = 'https://www.tu-chemnitz.de/etit/proaut/datasets/' + fn
161 |
162 | # create folders
163 | path = os.path.expanduser(destination)
164 | os.makedirs(path, exist_ok=True)
165 |
166 | # download
167 | urllib.request.urlretrieve(url, path + fn)
168 |
169 | # unzip
170 | with zipfile.ZipFile(path + fn, 'r') as zip_ref:
171 | zip_ref.extractall(destination)
172 |
173 | # remove zipfile
174 | os.remove(destination + fn)
175 |
--------------------------------------------------------------------------------
/demo.py:
--------------------------------------------------------------------------------
1 | # =====================================================================
2 | # Copyright (C) 2023 Stefan Schubert, stefan.schubert@etit.tu-chemnitz.de
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU General Public License
15 | # along with this program. If not, see .
16 | # =====================================================================
17 | #
18 |
19 | import argparse
20 | import configparser
21 | import os
22 |
23 | from evaluation.metrics import createPR, recallAt100precision, recallAtK
24 | from evaluation import show_correct_and_wrong_matches
25 | from matching import matching
26 | from datasets.load_dataset import GardensPointDataset, StLuciaDataset, SFUDataset
27 | import numpy as np
28 |
29 | from matplotlib import pyplot as plt
30 |
31 |
32 | def main():
33 | parser = argparse.ArgumentParser(description='Visual Place Recognition: A Tutorial. Code repository supplementing our paper.')
34 | parser.add_argument('--descriptor', type=str, default='HDC-DELF', choices=['HDC-DELF', 'AlexNet', 'NetVLAD', 'PatchNetVLAD', 'CosPlace', 'EigenPlaces', 'SAD'], help='Select descriptor (default: HDC-DELF)')
35 | parser.add_argument('--dataset', type=str, default='GardensPoint', choices=['GardensPoint', 'StLucia', 'SFU'], help='Select dataset (default: GardensPoint)')
36 | args = parser.parse_args()
37 |
38 | print('========== Start VPR with {} descriptor on dataset {}'.format(args.descriptor, args.dataset))
39 |
40 | # load dataset
41 | print('===== Load dataset')
42 | if args.dataset == 'GardensPoint':
43 | dataset = GardensPointDataset()
44 | elif args.dataset == 'StLucia':
45 | dataset = StLuciaDataset()
46 | elif args.dataset == 'SFU':
47 | dataset = SFUDataset()
48 | else:
49 | raise ValueError('Unknown dataset: ' + args.dataset)
50 |
51 | imgs_db, imgs_q, GThard, GTsoft = dataset.load()
52 |
53 | if args.descriptor == 'HDC-DELF':
54 | from feature_extraction.feature_extractor_holistic import HDCDELF
55 | feature_extractor = HDCDELF()
56 | elif args.descriptor == 'AlexNet':
57 | from feature_extraction.feature_extractor_holistic import AlexNetConv3Extractor
58 | feature_extractor = AlexNetConv3Extractor()
59 | elif args.descriptor == 'SAD':
60 | from feature_extraction.feature_extractor_holistic import SAD
61 | feature_extractor = SAD()
62 | elif args.descriptor == 'NetVLAD' or args.descriptor == 'PatchNetVLAD':
63 | from feature_extraction.feature_extractor_patchnetvlad import PatchNetVLADFeatureExtractor
64 | from patchnetvlad.tools import PATCHNETVLAD_ROOT_DIR
65 | if args.descriptor == 'NetVLAD':
66 | configfile = os.path.join(PATCHNETVLAD_ROOT_DIR, 'configs/netvlad_extract.ini')
67 | else:
68 | configfile = os.path.join(PATCHNETVLAD_ROOT_DIR, 'configs/speed.ini')
69 | assert os.path.isfile(configfile)
70 | config = configparser.ConfigParser()
71 | config.read(configfile)
72 | feature_extractor = PatchNetVLADFeatureExtractor(config)
73 | elif args.descriptor == 'CosPlace':
74 | from feature_extraction.feature_extractor_cosplace import CosPlaceFeatureExtractor
75 | feature_extractor = CosPlaceFeatureExtractor()
76 | elif args.descriptor == 'EigenPlaces':
77 | from feature_extraction.feature_extractor_eigenplaces import EigenPlacesFeatureExtractor
78 | feature_extractor = EigenPlacesFeatureExtractor()
79 | else:
80 | raise ValueError('Unknown descriptor: ' + args.descriptor)
81 |
82 | if args.descriptor != 'PatchNetVLAD' and args.descriptor != 'SAD':
83 | print('===== Compute reference set descriptors')
84 | db_D_holistic = feature_extractor.compute_features(imgs_db)
85 | print('===== Compute query set descriptors')
86 | q_D_holistic = feature_extractor.compute_features(imgs_q)
87 |
88 | # normalize descriptors and compute S-matrix
89 | print('===== Compute cosine similarities S')
90 | db_D_holistic = db_D_holistic / np.linalg.norm(db_D_holistic , axis=1, keepdims=True)
91 | q_D_holistic = q_D_holistic / np.linalg.norm(q_D_holistic , axis=1, keepdims=True)
92 | S = np.matmul(db_D_holistic , q_D_holistic.transpose())
93 | elif args.descriptor == 'SAD':
94 | print('===== Compute reference set descriptors')
95 | db_D_holistic = feature_extractor.compute_features(imgs_db)
96 | print('===== Compute query set descriptors')
97 | q_D_holistic = feature_extractor.compute_features(imgs_q)
98 |
99 | # compute similarity matrix S with sum of absolute differences (SAD)
100 | print('===== Compute similarities S from sum of absolute differences (SAD)')
101 | S = np.empty([len(imgs_db), len(imgs_q)], 'float32')
102 | for i in range(S.shape[0]):
103 | for j in range(S.shape[1]):
104 | diff = db_D_holistic[i]-q_D_holistic[j]
105 | dim = len(db_D_holistic[0]) - np.sum(np.isnan(diff))
106 | diff[np.isnan(diff)] = 0
107 | S[i,j] = -np.sum(np.abs(diff)) / dim
108 | else:
109 | print('=== WARNING: The PatchNetVLAD code in this repository is not optimised and will be slow and memory consuming.')
110 | print('===== Compute reference set descriptors')
111 | db_D_holistic, db_D_patches = feature_extractor.compute_features(imgs_db)
112 | print('===== Compute query set descriptors')
113 | q_D_holistic, q_D_patches = feature_extractor.compute_features(imgs_q)
114 | # S_hol = np.matmul(db_D_holistic , q_D_holistic.transpose())
115 | S = feature_extractor.local_matcher_from_numpy_single_scale(q_D_patches, db_D_patches)
116 |
117 | # show similarity matrix
118 | fig = plt.figure()
119 | plt.imshow(S)
120 | plt.axis('off')
121 | plt.title('Similarity matrix S')
122 |
123 | # matching decision making
124 | print('===== Match images')
125 |
126 | # best match per query -> Single-best-match VPR
127 | M1 = matching.best_match_per_query(S)
128 |
129 | # thresholding -> Multi-match VPR
130 | M2 = matching.thresholding(S, 'auto')
131 | TP = np.argwhere(M2 & GThard) # true positives
132 | FP = np.argwhere(M2 & ~GTsoft) # false positives
133 |
134 | # evaluation
135 | print('===== Evaluation')
136 | # show correct and wrong image matches
137 | show_correct_and_wrong_matches.show(
138 | imgs_db, imgs_q, TP, FP) # show random matches
139 |
140 | # show M's
141 | fig = plt.figure()
142 | ax1 = fig.add_subplot(121)
143 | ax1.imshow(M1)
144 | ax1.axis('off')
145 | ax1.set_title('Best match per query')
146 | ax2 = fig.add_subplot(122)
147 | ax2.imshow(M2)
148 | ax2.axis('off')
149 | ax2.set_title('Thresholding S>=thresh')
150 |
151 | # PR-curve
152 | P, R = createPR(S, GThard, GTsoft, matching='multi', n_thresh=100)
153 | plt.figure()
154 | plt.plot(R, P)
155 | plt.xlim(0, 1), plt.ylim(0, 1.01)
156 | plt.xlabel('Recall')
157 | plt.ylabel('Precision')
158 | plt.title('Result on GardensPoint day_right--night_right')
159 | plt.grid('on')
160 | plt.draw()
161 |
162 | # area under curve (AUC)
163 | AUC = np.trapz(P, R)
164 | print(f'\n===== AUC (area under curve): {AUC:.3f}')
165 |
166 | # maximum recall at 100% precision
167 | maxR = recallAt100precision(S, GThard, GTsoft, matching='multi', n_thresh=100)
168 | print(f'\n===== R@100P (maximum recall at 100% precision): {maxR:.2f}')
169 |
170 | # recall at K
171 | RatK = {}
172 | for K in [1, 5, 10]:
173 | RatK[K] = recallAtK(S, GThard, GTsoft, K=K)
174 |
175 | print(f'\n===== recall@K (R@K) -- R@1: {RatK[1]:.3f}, R@5: {RatK[5]:.3f}, R@10: {RatK[10]:.3f}')
176 |
177 | plt.show()
178 |
179 |
180 | if __name__ == "__main__":
181 | main()
182 |
--------------------------------------------------------------------------------
/evaluation/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stschubert/VPR_Tutorial/e071309301f7b360449700371a69242ed65cad80/evaluation/__init__.py
--------------------------------------------------------------------------------
/evaluation/metrics.py:
--------------------------------------------------------------------------------
1 | # =====================================================================
2 | # Copyright (C) 2023 Stefan Schubert, stefan.schubert@etit.tu-chemnitz.de
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU General Public License
15 | # along with this program. If not, see .
16 | # =====================================================================
17 | #
18 | import numpy as np
19 |
20 |
21 | def createPR(S_in, GThard, GTsoft=None, matching='multi', n_thresh=100):
22 | """
23 | Calculates the precision and recall at n_thresh equally spaced threshold values
24 | for a given similarity matrix S_in and ground truth matrices GThard and GTsoft for
25 | single-best-match VPR or multi-match VPR.
26 |
27 | The matrices S_in, GThard and GTsoft are two-dimensional and should all have the
28 | same shape.
29 | The matrices GThard and GTsoft should be binary matrices, where the entries are
30 | only zeros or ones.
31 | The matrix S_in should have continuous values between -Inf and Inf. Higher values
32 | indicate higher similarity.
33 | The string matching should be set to either "single" or "multi" for single-best-
34 | match VPR or multi-match VPR.
35 | The integer n_tresh controls the number of threshold values and should be >1.
36 | """
37 |
38 | assert (S_in.shape == GThard.shape),"S_in and GThard must have the same shape"
39 | if GTsoft is not None:
40 | assert (S_in.shape == GTsoft.shape),"S_in and GTsoft must have the same shape"
41 | assert (S_in.ndim == 2),"S_in, GThard and GTsoft must be two-dimensional"
42 | assert (matching in ['single', 'multi']),"matching should contain one of the following strings: [single, multi]"
43 | assert (n_thresh > 1),"n_thresh must be >1"
44 |
45 | if GTsoft is not None and matching == 'single':
46 | raise ValueError(
47 | "GTSoft with single matching is not supported. "
48 | "Please use dilated hard ground truth directly. "
49 | "For more details, visit: https://github.com/stschubert/VPR_Tutorial"
50 | )
51 |
52 | # ensure logical datatype in GT and GTsoft
53 | GT = GThard.astype('bool')
54 | if GTsoft is not None:
55 | GTsoft = GTsoft.astype('bool')
56 |
57 | # copy S and set elements that are only true in GTsoft to min(S) to ignore them during evaluation
58 | S = S_in.copy()
59 | if GTsoft is not None:
60 | S[GTsoft & ~GT] = S.min()
61 |
62 | # single-best-match or multi-match VPR
63 | if matching == 'single':
64 | # count the number of ground-truth positives (GTP)
65 | GTP = np.count_nonzero(GT.any(0))
66 |
67 | # GT-values for best match per query (i.e., per column)
68 | GT = GT[np.argmax(S, axis=0), np.arange(GT.shape[1])]
69 |
70 | # similarities for best match per query (i.e., per column)
71 | S = np.max(S, axis=0)
72 |
73 | elif matching == 'multi':
74 | # count the number of ground-truth positives (GTP)
75 | GTP = np.count_nonzero(GT) # ground truth positives
76 |
77 | # init precision and recall vectors
78 | R = [0, ]
79 | P = [1, ]
80 |
81 | # select start and end treshold
82 | startV = S.max() # start-value for treshold
83 | endV = S.min() # end-value for treshold
84 |
85 | # iterate over different thresholds
86 | for i in np.linspace(startV, endV, n_thresh):
87 | B = S >= i # apply threshold
88 |
89 | TP = np.count_nonzero(GT & B) # true positives
90 | FP = np.count_nonzero((~GT) & B) # false positives
91 |
92 | P.append(TP / (TP + FP)) # precision
93 | R.append(TP / GTP) # recall
94 |
95 | return P, R
96 |
97 |
98 | def recallAt100precision(S_in, GThard, GTsoft=None, matching='multi', n_thresh=100):
99 | """
100 | Calculates the maximum recall at 100% precision for a given similarity matrix S_in
101 | and ground truth matrices GThard and GTsoft for single-best-match VPR or multi-match
102 | VPR.
103 |
104 | The matrices S_in, GThard and GTsoft are two-dimensional and should all have the
105 | same shape.
106 | The matrices GThard and GTsoft should be binary matrices, where the entries are
107 | only zeros or ones.
108 | The matrix S_in should have continuous values between -Inf and Inf. Higher values
109 | indicate higher similarity.
110 | The string matching should be set to either "single" or "multi" for single-best-
111 | match VPR or multi-match VPR.
112 | The integer n_tresh controls the number of threshold values during the creation of
113 | the precision-recall curve and should be >1.
114 | """
115 |
116 | assert (S_in.shape == GThard.shape),"S_in and GThard must have the same shape"
117 | if GTsoft is not None:
118 | assert (S_in.shape == GTsoft.shape),"S_in and GTsoft must have the same shape"
119 | assert (S_in.ndim == 2),"S_in, GThard and GTsoft must be two-dimensional"
120 | assert (matching in ['single', 'multi']),"matching should contain one of the following strings: [single, multi]"
121 | assert (n_thresh > 1),"n_thresh must be >1"
122 |
123 | if GTsoft is not None and matching == 'single':
124 | raise ValueError(
125 | "GTSoft with single matching is not supported. "
126 | "Please use dilated hard ground truth directly. "
127 | "For more details, visit: https://github.com/stschubert/VPR_Tutorial"
128 | )
129 |
130 | # get precision-recall curve
131 | P, R = createPR(S_in, GThard, GTsoft, matching=matching, n_thresh=n_thresh)
132 | P = np.array(P)
133 | R = np.array(R)
134 |
135 | # recall values at 100% precision
136 | R = R[P==1]
137 |
138 | # maximum recall at 100% precision
139 | R = R.max()
140 |
141 | return R
142 |
143 |
144 | def recallAtK(S, GT, K=1):
145 | """
146 | Calculates the recall@K for a given similarity matrix S and ground truth matrix GT.
147 | Note that this method does not support GTsoft - instead, please directly provide
148 | the dilated ground truth matrix as GT.
149 |
150 | The matrices S and GT are two-dimensional and should all have the same shape.
151 | The matrix GT should be binary, where the entries are only zeros or ones.
152 | The matrix S should have continuous values between -Inf and Inf. Higher values
153 | indicate higher similarity.
154 | The integer K>=1 defines the number of matching candidates that are selected and
155 | that must contain an actually matching image pair.
156 | """
157 |
158 | assert (S.shape == GT.shape),"S and GT must have the same shape"
159 | assert (S.ndim == 2),"S and GT must be two-dimensional"
160 | assert (K >= 1),"K must be >=1"
161 |
162 | # ensure logical datatype in GT
163 | GT = GT.astype('bool')
164 |
165 | # discard all query images without an actually matching database image
166 | j = GT.sum(0) > 0 # columns with matches
167 | S = S[:,j] # select columns with a match
168 | GT = GT[:,j] # select columns with a match
169 |
170 | # select K highest similarities
171 | i = S.argsort(0)[-K:,:]
172 | j = np.tile(np.arange(i.shape[1]), [K, 1])
173 | GT = GT[i, j]
174 |
175 | # recall@K
176 | RatK = np.sum(GT.sum(0) > 0) / GT.shape[1]
177 |
178 | return RatK
179 |
--------------------------------------------------------------------------------
/evaluation/show_correct_and_wrong_matches.py:
--------------------------------------------------------------------------------
1 | # =====================================================================
2 | # Copyright (C) 2023 Stefan Schubert, stefan.schubert@etit.tu-chemnitz.de
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU General Public License
15 | # along with this program. If not, see .
16 | # =====================================================================
17 | #
18 | from matplotlib import pyplot as plt
19 | import numpy as np
20 | from skimage.transform import resize
21 | from typing import Tuple, List, Optional
22 |
23 |
24 | def add_frame(img_in: np.ndarray, color: Tuple[int, int, int]) -> np.ndarray:
25 | """
26 | Adds a colored frame around an input image.
27 |
28 | Args:
29 | img_in (np.ndarray): A three-dimensional array representing the input image (height, width, channels).
30 | color (Tuple[int, int, int]): A tuple of three integers representing the RGB color of the frame.
31 |
32 | Returns:
33 | np.ndarray: A three-dimensional array representing the image with the added frame.
34 | """
35 | img = img_in.copy()
36 |
37 | w = int(np.round(0.01*img.shape[1]))
38 |
39 | # pad left-right
40 | pad_lr = np.tile(np.uint8(color).reshape(1, 1, 3), (img.shape[0], w, 1))
41 | img = np.concatenate([pad_lr, img, pad_lr], axis=1)
42 |
43 | # pad top-bottom
44 | pad_tb = np.tile(np.uint8(color).reshape(1, 1, 3), (w, img.shape[1], 1))
45 | img = np.concatenate([pad_tb, img, pad_tb], axis=0)
46 |
47 | return img
48 |
49 |
50 | def show(
51 | db_imgs: List[np.ndarray],
52 | q_imgs: List[np.ndarray],
53 | TP: np.ndarray,
54 | FP: np.ndarray,
55 | M: Optional[np.ndarray] = None,
56 | ) -> None:
57 | """
58 | Displays a visual comparison of true positive and false positive image pairs
59 | from a database and query set. Optionally, a similarity matrix can be included.
60 |
61 | Args:
62 | db_imgs (List[np.ndarray]): A list of 3D arrays representing the database images (height, width, channels).
63 | q_imgs (List[np.ndarray]): A list of 3D arrays representing the query images (height, width, channels).
64 | TP (np.ndarray): A two-dimensional array containing the indices of true positive pairs.
65 | FP (np.ndarray): A two-dimensional array containing the indices of false positive pairs.
66 | M (Optional[np.ndarray], optional): A two-dimensional array representing the similarity matrix. Defaults to None.
67 |
68 | Returns:
69 | None: This function displays the comparison result using matplotlib.pyplot but does not return any value.
70 | """
71 | # true positive TP
72 | if(len(TP) == 0):
73 | print('No true positives found.')
74 | return
75 |
76 | idx_tp = np.random.permutation(len(TP))[:1]
77 |
78 | db_tp = db_imgs[int(TP[idx_tp, 0])]
79 | q_tp = q_imgs[int(TP[idx_tp, 1])]
80 |
81 | if db_tp.shape != q_tp.shape:
82 | q_tp = resize(q_tp.copy(), db_tp.shape, anti_aliasing=True)
83 | q_tp = np.uint8(q_tp*255)
84 |
85 | img = add_frame(np.concatenate([db_tp, q_tp], axis=1), [119, 172, 48])
86 |
87 | # false positive FP
88 | try:
89 | idx_fp = np.random.permutation(len(FP))[:1]
90 |
91 | db_fp = db_imgs[int(FP[idx_fp, 0])]
92 | q_fp = q_imgs[int(FP[idx_fp, 1])]
93 |
94 | if db_fp.shape != q_fp.shape:
95 | q_fp = resize(q_fp.copy(), db_fp.shape, anti_aliasing=True)
96 | q_fp = np.uint8(q_fp*255)
97 |
98 | img_fp = add_frame(np.concatenate(
99 | [db_fp, q_fp], axis=1), [162, 20, 47])
100 | img = np.concatenate([img, img_fp], axis=0)
101 | except:
102 | pass
103 |
104 | # concat M
105 | if M is not None:
106 | M = resize(M.copy(), (img.shape[0], img.shape[0]))
107 | M = np.uint8(M.astype('float32')*255)
108 | M = np.tile(np.expand_dims(M, -1), (1, 1, 3))
109 | img = np.concatenate([M, img], axis=1)
110 |
111 | # show
112 | plt.figure()
113 | plt.imshow(img)
114 | plt.axis('off')
115 | plt.title('Examples for correct and wrong matches from S>=thresh')
116 |
--------------------------------------------------------------------------------
/feature_aggregation/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stschubert/VPR_Tutorial/e071309301f7b360449700371a69242ed65cad80/feature_aggregation/__init__.py
--------------------------------------------------------------------------------
/feature_aggregation/hdc.py:
--------------------------------------------------------------------------------
1 | # =====================================================================
2 | # Copyright (C) 2023 Stefan Schubert, stefan.schubert@etit.tu-chemnitz.de
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU General Public License
15 | # along with this program. If not, see .
16 | # =====================================================================
17 | #
18 | import numpy as np
19 | from scipy.linalg import orth
20 | from typing import Union, Dict, List, Tuple
21 |
22 |
23 | class HDC(object):
24 | """
25 | A class for implementing Hyperdimensional Computing (HDC).
26 | """
27 |
28 | def __init__(self, Ds: List[Dict[str, np.ndarray]], nDims: int = 4096, nFeat: int = 200, nX: int = 5, nY: int = 7):
29 | """
30 | Initializes the HDC object with the given parameters.
31 |
32 | Args:
33 | Ds (List[Dict[str, np.ndarray]]): A list of dictionaries containing
34 | descriptors.
35 | nDims (int, optional): The number of dimensions for the HDC vectors.
36 | Defaults to 4096.
37 | nFeat (int, optional): The number of features. Defaults to 200.
38 | nX (int, optional): The number of HDC vectors for the X-axis. Defaults
39 | to 5.
40 | nY (int, optional): The number of HDC vectors for the Y-axis. Defaults
41 | to 7.
42 | """
43 | indim = Ds[0]['descriptors'].shape[1]
44 | self.nDims = nDims
45 | self.nFeat = nFeat
46 | self.nX = nX
47 | self.nY = nY
48 | self.Ds = Ds
49 |
50 | # random projection matrix for descriptors
51 | rng = np.random.default_rng(123)
52 | self.Proj = rng.standard_normal((indim, self.nDims), 'float32')
53 | self.Proj = orth(self.Proj.transpose()).transpose()
54 |
55 | # hdc vectors for poses
56 | rng = np.random.default_rng(456)
57 | self.X = (1. - 2. * (rng.random((nX, nDims), 'float32') > 0.5)
58 | ).astype('float32')
59 | self.posX = np.linspace(0., 1., nX)
60 |
61 | rng = np.random.default_rng(789)
62 | self.Y = (1. - 2. * (rng.random((nY, nDims), 'float32') > 0.5)
63 | ).astype('float32')
64 | self.posY = np.linspace(0., 1., nY)
65 |
66 |
67 | def compute_holistic(self) -> np.ndarray:
68 | """
69 | Computes the holistic HDC descriptors for each entry in self.Ds.
70 |
71 | Returns:
72 | np.ndarray: A two-dimensional array with the shape (len(self.Ds), self.nDims),
73 | containing the holistic HDC descriptors for each entry in self.Ds.
74 | """
75 | Y = np.zeros((len(self.Ds), self.nDims), 'float32')
76 |
77 | for i in range(len(self.Ds)):
78 | # load i-th descriptor
79 | D = self.Ds[i]
80 |
81 | # standardize local descriptors
82 | D['descriptors'] = D['descriptors'] @ self.Proj
83 | D['descriptors'] = self.__STD(D['descriptors'])
84 |
85 | # compute holistic HDC-descriptor
86 | Y[i, :] = self.__bundleLocalDescriptorsIndividually(D)
87 |
88 | return Y
89 |
90 |
91 | def __bundleLocalDescriptorsIndividually(self, D: Dict[str, Union[np.ndarray, int]]) -> np.ndarray:
92 | """
93 | Binds each local descriptor to its pose and bundles them to compute the holistic
94 | HDC descriptor for a given input dictionary D.
95 |
96 | Args:
97 | D (Dict[str, Union[np.ndarray, int]]): A dictionary containing the following keys:
98 | - 'descriptors': A two-dimensional array containing the local descriptors.
99 | - 'keypoints': A two-dimensional array containing the keypoints.
100 | - 'imheight': An integer representing the image height.
101 | - 'imwidth': An integer representing the image width.
102 |
103 | Returns:
104 | np.ndarray: A one-dimensional array of length self.nDims, containing the
105 | holistic HDC descriptor for the input dictionary D.
106 | """
107 | desc = D['descriptors']
108 | keypoints = D['keypoints']
109 | h = D['imheight']
110 | w = D['imwidth']
111 | nDims = self.nDims
112 |
113 | # encode poses
114 | PV = self.__encodePosesHDCconcatMultiAttractor(
115 | keypoints, h, w, nDims)
116 |
117 | # bind each descriptor to its pose and bundle
118 | H = np.sum(desc * PV, 0)
119 | return H
120 |
121 |
122 | def __encodePosesHDCconcatMultiAttractor(self, P: np.ndarray, h: int, w: int, nDims: int) -> np.ndarray:
123 | """
124 | Encodes the poses of keypoints using the HDC multi-attractor approach.
125 |
126 | Args:
127 | P (np.ndarray): A two-dimensional array containing the keypoints.
128 | h (int): The image height.
129 | w (int): The image width.
130 | nDims (int): The number of dimensions for the HDC vectors.
131 |
132 | Returns:
133 | np.ndarray: A two-dimensional array with the shape (P.shape[0], nDims),
134 | containing the encoded HDC poses for the keypoints.
135 | """
136 | # relative poses for keypoints
137 | xr = P[:, 1] / w
138 | yr = P[:, 0] / h
139 |
140 | PV = np.zeros((P.shape[0], nDims), 'float32')
141 | for i in range(xr.size):
142 | # find attractors and split index
143 | Xidx1, Xidx2, XsplitIdx = self.__findAttractorsAndSplitIdx(
144 | xr[i], self.posX, nDims)
145 | Yidx1, Yidx2, YsplitIdx = self.__findAttractorsAndSplitIdx(
146 | yr[i], self.posY, nDims)
147 |
148 | # apply
149 | xVec = np.concatenate(
150 | (self.X[Xidx1, :XsplitIdx], self.X[Xidx2, XsplitIdx:]))
151 | yVec = np.concatenate(
152 | (self.Y[Yidx1, :YsplitIdx], self.Y[Yidx2, YsplitIdx:]))
153 |
154 | # combine
155 | PV[i, :] = xVec * yVec
156 |
157 | return PV
158 |
159 |
160 | def __findAttractorsAndSplitIdx(self, p: float, pos: np.ndarray, nDims: int) -> Tuple[int, int, int]:
161 | """
162 | Finds the two closest attractors to the given position and computes the
163 | split index for combining the HDC vectors.
164 |
165 | Args:
166 | p (float): The position of the point in question.
167 | pos (np.ndarray): A one-dimensional array containing the attractor positions.
168 | nDims (int): The number of dimensions for the HDC vectors.
169 |
170 | Returns:
171 | Tuple[int, int, int]: A tuple containing:
172 | - The index of the first closest attractor.
173 | - The index of the second closest attractor.
174 | - The split index for combining the HDC vectors.
175 | """
176 | # find closest vectors
177 | idx = np.argpartition(abs(pos-p), 2)[:2]
178 | idx.sort()
179 | idx1, idx2 = idx
180 |
181 | # find weighting of both
182 | d1 = abs(pos[idx1]-p)
183 | d2 = abs(pos[idx2]-p)
184 | w = d2 / (d1 + d2)
185 |
186 | # compute index from weighting
187 | splitIdx = round(w*nDims)
188 |
189 | # return indices
190 | return idx1, idx2, splitIdx
191 |
192 |
193 | def __STD(self, D: np.ndarray) -> np.ndarray:
194 | """
195 | Standardizes the input descriptors by subtracting the mean and dividing by the standard deviation.
196 |
197 | Args:
198 | D (np.ndarray): A two-dimensional array containing the descriptors to be standardized.
199 |
200 | Returns:
201 | np.ndarray: A two-dimensional array containing the standardized descriptors.
202 | """
203 |
204 | mu = D.mean(0)
205 | sig = D.std(0)
206 |
207 | D = (D-mu) / sig
208 |
209 | return D
210 |
--------------------------------------------------------------------------------
/feature_extraction/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stschubert/VPR_Tutorial/e071309301f7b360449700371a69242ed65cad80/feature_extraction/__init__.py
--------------------------------------------------------------------------------
/feature_extraction/feature_extractor.py:
--------------------------------------------------------------------------------
1 | # =====================================================================
2 | # Copyright (C) 2023 Stefan Schubert, stefan.schubert@etit.tu-chemnitz.de
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU General Public License
15 | # along with this program. If not, see .
16 | # =====================================================================
17 | #
18 |
19 | from abc import ABC, abstractmethod
20 | import numpy as np
21 | from typing import List
22 |
23 |
24 | class FeatureExtractor(ABC):
25 |
26 | @abstractmethod
27 | def compute_features(self, imgs: List[np.ndarray]) -> np.ndarray:
28 | pass
29 |
--------------------------------------------------------------------------------
/feature_extraction/feature_extractor_cosplace.py:
--------------------------------------------------------------------------------
1 |
2 | import torch
3 | from torch.utils.data import DataLoader
4 | import torchvision.transforms as transforms
5 | import torch.utils.data as data
6 |
7 | from typing import List
8 | import numpy as np
9 | from tqdm.auto import tqdm
10 |
11 |
12 | class ImageDataset(data.Dataset):
13 | def __init__(self, imgs):
14 | super().__init__()
15 | self.mytransform = self.input_transform()
16 | self.images = imgs
17 |
18 | def __getitem__(self, index):
19 | img = self.images[index]
20 | img = self.mytransform(img)
21 |
22 | return img, index
23 |
24 | def __len__(self):
25 | return len(self.images)
26 |
27 | @staticmethod
28 | def input_transform():
29 | return transforms.Compose([
30 | transforms.ToPILImage(),
31 | transforms.Resize(480),
32 | transforms.ToTensor(),
33 | transforms.Normalize(mean=[0.485, 0.456, 0.406],
34 | std=[0.229, 0.224, 0.225]),
35 | ])
36 |
37 |
38 | class CosPlaceFeatureExtractor(torch.nn.Module):
39 | def __init__(self):
40 | super().__init__()
41 |
42 | if torch.cuda.is_available():
43 | print('Using GPU')
44 | self.device = torch.device("cuda")
45 | elif torch.backends.mps.is_available() and torch.backends.mps.is_built():
46 | print('Using MPS')
47 | self.device = torch.device("mps")
48 | else:
49 | print('Using CPU')
50 | self.device = torch.device("cpu")
51 | self.model = torch.hub.load("gmberton/cosplace", "get_trained_model",
52 | backbone="ResNet50", fc_output_dim=2048)
53 | self.dim = 2048
54 | self.model = self.model.to(self.device)
55 |
56 | def compute_features(self, imgs: List[np.ndarray]) -> np.ndarray:
57 | img_set = ImageDataset(imgs)
58 | test_data_loader = DataLoader(dataset=img_set, num_workers=4,
59 | batch_size=4, shuffle=False,
60 | pin_memory=torch.cuda.is_available())
61 | self.model.eval()
62 | with torch.no_grad():
63 | global_feats = np.empty((len(img_set), self.dim), dtype=np.float32)
64 | for input_data, indices in tqdm(test_data_loader):
65 | indices_np = indices.numpy()
66 | input_data = input_data.to(self.device)
67 | image_encoding = self.model(input_data)
68 | global_feats[indices_np, :] = image_encoding.cpu().numpy()
69 | return global_feats
70 |
71 |
72 |
--------------------------------------------------------------------------------
/feature_extraction/feature_extractor_eigenplaces.py:
--------------------------------------------------------------------------------
1 |
2 | import torch
3 | from torch.utils.data import DataLoader
4 | import torchvision.transforms as transforms
5 | import torch.utils.data as data
6 |
7 | from typing import List
8 | import numpy as np
9 | from tqdm.auto import tqdm
10 |
11 |
12 | class ImageDataset(data.Dataset):
13 | def __init__(self, imgs):
14 | super().__init__()
15 | self.mytransform = self.input_transform()
16 | self.images = imgs
17 |
18 | def __getitem__(self, index):
19 | img = self.images[index]
20 | img = self.mytransform(img)
21 |
22 | return img, index
23 |
24 | def __len__(self):
25 | return len(self.images)
26 |
27 | @staticmethod
28 | def input_transform():
29 | return transforms.Compose([
30 | transforms.ToPILImage(),
31 | transforms.Resize(480),
32 | transforms.ToTensor(),
33 | transforms.Normalize(mean=[0.485, 0.456, 0.406],
34 | std=[0.229, 0.224, 0.225]),
35 | ])
36 |
37 |
38 | class EigenPlacesFeatureExtractor(torch.nn.Module):
39 | def __init__(self):
40 | super().__init__()
41 |
42 | if torch.cuda.is_available():
43 | print('Using GPU')
44 | self.device = torch.device("cuda")
45 | elif torch.backends.mps.is_available() and torch.backends.mps.is_built():
46 | print('Using MPS')
47 | self.device = torch.device("mps")
48 | else:
49 | print('Using CPU')
50 | self.device = torch.device("cpu")
51 | self.model = torch.hub.load("gmberton/eigenplaces", "get_trained_model",
52 | backbone="ResNet50", fc_output_dim=2048)
53 | self.dim = 2048
54 | self.model = self.model.to(self.device)
55 |
56 | def compute_features(self, imgs: List[np.ndarray]) -> np.ndarray:
57 | img_set = ImageDataset(imgs)
58 | test_data_loader = DataLoader(dataset=img_set, num_workers=4,
59 | batch_size=4, shuffle=False,
60 | pin_memory=torch.cuda.is_available())
61 | self.model.eval()
62 | with torch.no_grad():
63 | global_feats = np.empty((len(img_set), self.dim), dtype=np.float32)
64 | for input_data, indices in tqdm(test_data_loader):
65 | indices_np = indices.numpy()
66 | input_data = input_data.to(self.device)
67 | image_encoding = self.model(input_data)
68 | global_feats[indices_np, :] = image_encoding.cpu().numpy()
69 | return global_feats
70 |
71 |
72 |
--------------------------------------------------------------------------------
/feature_extraction/feature_extractor_holistic.py:
--------------------------------------------------------------------------------
1 | # =====================================================================
2 | # Copyright (C) 2023 Stefan Schubert, stefan.schubert@etit.tu-chemnitz.de
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU General Public License
15 | # along with this program. If not, see .
16 | # =====================================================================
17 | #
18 |
19 | import numpy as np
20 | from typing import List
21 |
22 | from .feature_extractor import FeatureExtractor
23 |
24 |
25 | class AlexNetConv3Extractor(FeatureExtractor):
26 | def __init__(self, nDims: int = 4096):
27 | import torch
28 | from torchvision import transforms
29 |
30 | self.nDims = nDims
31 | # load alexnet
32 | self.model = torch.hub.load('pytorch/vision:v0.10.0', 'alexnet', pretrained=True)
33 |
34 | # select conv3
35 | self.model = self.model.features[:7]
36 |
37 | # preprocess images
38 | self.preprocess = transforms.Compose([
39 | transforms.ToPILImage(),
40 | transforms.Resize([224, 224]),
41 | # transforms.CenterCrop(224),
42 | transforms.ToTensor(),
43 | transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
44 | ])
45 |
46 | if torch.cuda.is_available():
47 | print('Using GPU')
48 | self.device = torch.device("cuda")
49 | elif torch.backends.mps.is_available() and torch.backends.mps.is_built():
50 | print('Using MPS')
51 | self.device = torch.device("mps")
52 | else:
53 | print('Using CPU')
54 | self.device = torch.device("cpu")
55 |
56 | self.model.to(self.device)
57 |
58 |
59 | def compute_features(self, imgs: List[np.ndarray]) -> List[np.ndarray]:
60 | import torch
61 |
62 | imgs_torch = [self.preprocess(img) for img in imgs]
63 | imgs_torch = torch.stack(imgs_torch, dim=0)
64 |
65 | imgs_torch = imgs_torch.to(self.device)
66 |
67 | with torch.no_grad():
68 | output = self.model(imgs_torch)
69 |
70 | output = output.to('cpu').numpy()
71 | Ds = output.reshape([len(imgs), -1])
72 |
73 | rng = np.random.default_rng(seed=0)
74 | Proj = rng.standard_normal([Ds.shape[1], self.nDims], 'float32')
75 | Proj = Proj / np.linalg.norm(Proj , axis=1, keepdims=True)
76 |
77 | Ds = Ds @ Proj
78 |
79 | return Ds
80 |
81 |
82 | class HDCDELF(FeatureExtractor):
83 | def __init__(self):
84 | from .feature_extractor_local import DELF
85 |
86 | self.DELF = DELF() # local DELF descriptor
87 |
88 | def compute_features(self, imgs: List[np.ndarray]) -> np.ndarray:
89 | from feature_aggregation.hdc import HDC
90 |
91 | D_local = self.DELF.compute_features(imgs)
92 | D_holistic = HDC(D_local).compute_holistic()
93 |
94 | return D_holistic
95 |
96 |
97 | # sum of absolute differences (SAD) [Milford and Wyeth (2012). "SeqSLAM: Visual
98 | # Route-Based Navigation for Sunny Summer Days and Stormy Winter Nights". ICRA.]
99 | class SAD(FeatureExtractor):
100 | def __init__(self, nPixels: int = 2048, patchLength: int = 8):
101 | self.nPixels = nPixels # number pixels in downsampled image
102 | self.patchLength = patchLength # side length of patches for patch normalization
103 |
104 | self.imshapeDownsampled = None
105 |
106 | def compute_features(self, imgs: List[np.ndarray]) -> np.ndarray:
107 |
108 | # determine new image shape to obtain roughly self.nPixels pixels and
109 | # image dimensions that are a multiple of self.patchLength
110 | if self.imshapeDownsampled is None:
111 | [h,w,_] = np.array(imgs[0].shape)
112 |
113 | k = np.sqrt(self.nPixels / (h * w))
114 | h = np.ceil(k * h)
115 | h -= np.mod(h, self.patchLength)
116 | w = np.ceil(k * w)
117 | w -= np.mod(w, self.patchLength)
118 |
119 | if np.abs(self.nPixels - h*w) > np.abs(self.nPixels - (h+self.patchLength)*(w+self.patchLength)):
120 | h += self.patchLength
121 | w += self.patchLength
122 |
123 | self.imshapeDownsampled = [int(h), int(w)]
124 |
125 | # grayscale conversion and downsampling
126 | from torchvision import transforms
127 | preprocess = transforms.Compose([
128 | transforms.ToPILImage(),
129 | transforms.Grayscale(),
130 | transforms.Resize(self.imshapeDownsampled),
131 | ])
132 | imgs = [np.array(preprocess(img)) for img in imgs]
133 |
134 | # patch normalization
135 | Ds = [self.__patch_normalize(img).flatten() for img in imgs]
136 | Ds = np.array(Ds).astype('float32')
137 |
138 | return Ds
139 |
140 | def __patch_normalize(self, img: np.ndarray) -> np.ndarray:
141 | np.seterr(divide='ignore', invalid='ignore') # ignore potential division by 0
142 |
143 | img = img.astype('float32')
144 | [h,w] = img.shape
145 | for i_h in range(h // self.patchLength):
146 | for i_w in range(w // self.patchLength):
147 | patch = img[i_h*self.patchLength:(i_h+1)*self.patchLength, i_w*self.patchLength:(i_w+1)*self.patchLength]
148 | patch_normalized = 255 * ((patch - patch.min()) / (patch.max() - patch.min()))
149 | patch = patch_normalized.round()
150 | img[i_h*self.patchLength:(i_h+1)*self.patchLength, i_w*self.patchLength:(i_w+1)*self.patchLength] = patch
151 |
152 | np.seterr(divide='warn', invalid='warn')
153 |
154 | return img
155 |
--------------------------------------------------------------------------------
/feature_extraction/feature_extractor_local.py:
--------------------------------------------------------------------------------
1 | # =====================================================================
2 | # Copyright (C) 2023 Stefan Schubert, stefan.schubert@etit.tu-chemnitz.de
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU General Public License
15 | # along with this program. If not, see .
16 | # =====================================================================
17 | #
18 |
19 | import numpy as np
20 | from typing import List
21 | from abc import abstractmethod
22 |
23 | from tqdm.auto import tqdm
24 |
25 | from .feature_extractor import FeatureExtractor
26 |
27 |
28 | class LocalFeatureExtractor(FeatureExtractor):
29 |
30 | @abstractmethod
31 | def compute_local_features(self, imgs: List[np.ndarray]) -> List[np.ndarray]:
32 | pass
33 |
34 |
35 | class DELF(LocalFeatureExtractor):
36 | def __init__(self):
37 | import tensorflow_hub as hub
38 |
39 | self.delf = hub.load('https://tfhub.dev/google/delf/1').signatures['default']
40 |
41 | def compute_features(self, imgs: List[np.ndarray]) -> np.ndarray:
42 |
43 | D_local = self.compute_local_features(imgs)
44 |
45 | return D_local
46 |
47 | def compute_local_features(self, imgs: List[np.ndarray]) -> List[np.ndarray]:
48 | D = []
49 | for img in tqdm(imgs):
50 | D.append(self.compute_local_delf_descriptor(img))
51 |
52 | return D
53 |
54 | def compute_local_delf_descriptor(self, img: np.ndarray):
55 | import tensorflow as tf
56 |
57 | im_height = img.shape[0]
58 | im_width = img.shape[1]
59 | img = tf.image.convert_image_dtype(img, tf.float32)
60 |
61 | out = self.delf(image=img,
62 | score_threshold=tf.constant(0.0),
63 | image_scales=tf.constant([1.0]),
64 | max_feature_num=tf.constant(200))
65 |
66 | return {'descriptors': np.array(out['features']),
67 | 'descriptors_pca': np.array(out['descriptors']),
68 | 'scores': np.array(out['scores']),
69 | 'keypoints': np.array(out['locations']),
70 | 'scales': 1./np.array(out['scales']),
71 | 'imheight': np.array(im_height),
72 | 'imwidth': np.array(im_width)
73 | }
74 |
--------------------------------------------------------------------------------
/feature_extraction/feature_extractor_patchnetvlad.py:
--------------------------------------------------------------------------------
1 | import torch
2 | from torch.utils.data import DataLoader
3 | import torchvision.transforms as transforms
4 | import torch.utils.data as data
5 | import subprocess
6 |
7 | from PIL import Image
8 |
9 | from os.path import join, isfile
10 | from typing import List
11 | import numpy as np
12 | from tqdm.auto import tqdm
13 |
14 | from patchnetvlad.models.models_generic import get_backend, get_model, get_pca_encoding
15 | from patchnetvlad.tools import PATCHNETVLAD_ROOT_DIR
16 |
17 | from .feature_extractor import FeatureExtractor
18 |
19 |
20 | class ImageDataset(data.Dataset):
21 | def __init__(self, imgs):
22 | super().__init__()
23 | self.mytransform = self.input_transform()
24 | self.images = imgs
25 |
26 | def __getitem__(self, index):
27 | # img = Image.open(self.images[index])
28 | # TODO: Check if the channel order is correct
29 | img = self.images[index]
30 | img = self.mytransform(img)
31 |
32 | return img, index
33 |
34 | def __len__(self):
35 | return len(self.images)
36 |
37 | @staticmethod
38 | def input_transform(resize=(480, 640)):
39 | if resize[0] > 0 and resize[1] > 0:
40 | return transforms.Compose([
41 | transforms.ToPILImage(),
42 | transforms.Resize(resize),
43 | transforms.ToTensor(),
44 | transforms.Normalize(mean=[0.485, 0.456, 0.406],
45 | std=[0.229, 0.224, 0.225]),
46 | ])
47 | else:
48 | return transforms.Compose([
49 | transforms.ToPILImage(),
50 | transforms.ToTensor(),
51 | transforms.Normalize(mean=[0.485, 0.456, 0.406],
52 | std=[0.229, 0.224, 0.225]),
53 | ])
54 |
55 | class PatchNetVLADFeatureExtractor(FeatureExtractor):
56 | def __init__(self, config):
57 | self.config = config
58 |
59 | if torch.cuda.is_available():
60 | print('Using GPU')
61 | self.device = torch.device("cuda")
62 | elif torch.backends.mps.is_available() and torch.backends.mps.is_built():
63 | print('Using MPS')
64 | self.device = torch.device("mps")
65 | else:
66 | print('Using CPU')
67 | self.device = torch.device("cpu")
68 |
69 | encoder_dim, encoder = get_backend()
70 |
71 | if self.config['global_params']['num_pcs'] != '0':
72 | resume_ckpt = self.config['global_params']['resumePath'] + self.config['global_params']['num_pcs'] + '.pth.tar'
73 | else:
74 | resume_ckpt = self.config['global_params']['resumePath'] + '.pth.tar'
75 |
76 | if not isfile(resume_ckpt):
77 | resume_ckpt = join(PATCHNETVLAD_ROOT_DIR, resume_ckpt)
78 | if not isfile(resume_ckpt):
79 | print('Downloading Patch-NetVLAD models, this might take a while ...')
80 | subprocess.run(["patchnetvlad-download-models"])
81 |
82 |
83 | if isfile(resume_ckpt):
84 | print("=> loading checkpoint '{}'".format(resume_ckpt))
85 | checkpoint = torch.load(resume_ckpt, map_location=lambda storage, loc: storage)
86 | if self.config['global_params']['num_pcs'] != '0':
87 | assert checkpoint['state_dict']['WPCA.0.bias'].shape[0] == int(self.config['global_params']['num_pcs'])
88 | self.config['global_params']['num_clusters'] = str(checkpoint['state_dict']['pool.centroids'].shape[0])
89 |
90 | if self.config['global_params']['num_pcs'] != '0':
91 | use_pca = True
92 | else:
93 | use_pca = False
94 | self.model = get_model(encoder, encoder_dim, self.config['global_params'], append_pca_layer=use_pca)
95 | self.model.load_state_dict(checkpoint['state_dict'])
96 |
97 | if int(self.config['global_params']['nGPU']) > 1 and torch.cuda.device_count() > 1:
98 | self.model.encoder = torch.nn.DataParallel(self.model.encoder)
99 | self.model.pool = torch.nn.DataParallel(self.model.pool)
100 |
101 | self.model = self.model.to(self.device)
102 | print(f"=> loaded checkpoint '{resume_ckpt}'")
103 | else:
104 | raise FileNotFoundError(f"=> no checkpoint found at '{resume_ckpt}'")
105 |
106 | if self.config['global_params']['pooling'].lower() == 'patchnetvlad':
107 | self.num_patches = self.get_num_patches()
108 | else:
109 | self.num_patches = None
110 |
111 |
112 | def get_num_patches(self):
113 | H = int(int(self.config['feature_match']['imageresizeH']) / 16) # 16 is the vgg scaling from image space to feature space (conv5)
114 | W = int(int(self.config['feature_match']['imageresizeW']) / 16)
115 | padding_size = [0, 0]
116 | patch_sizes = [int(s) for s in self.config['global_params']['patch_sizes'].split(",")]
117 | patch_size = (int(patch_sizes[0]), int(patch_sizes[0]))
118 | strides = [int(s) for s in self.config['global_params']['strides'].split(",")]
119 | stride = (int(strides[0]), int(strides[0]))
120 |
121 | Hout = int((H + (2 * padding_size[0]) - patch_size[0]) / stride[0] + 1)
122 | Wout = int((W + (2 * padding_size[1]) - patch_size[1]) / stride[1] + 1)
123 |
124 | num_regions = Hout * Wout
125 | return num_regions
126 |
127 |
128 | def compute_features(self, imgs: List[np.ndarray]) -> np.ndarray:
129 | pool_size = int(self.config['global_params']['num_pcs'])
130 |
131 | img_set = ImageDataset(imgs)
132 | test_data_loader = DataLoader(dataset=img_set, num_workers=int(self.config['global_params']['threads']),
133 | batch_size=int(self.config['feature_extract']['cacheBatchSize']),
134 | shuffle=False, pin_memory=torch.cuda.is_available())
135 |
136 | self.model.eval()
137 | with torch.no_grad():
138 | global_feats = np.empty((len(img_set), pool_size), dtype=np.float32)
139 | if self.config['global_params']['pooling'].lower() == 'patchnetvlad':
140 | patch_feats = np.empty((len(img_set), pool_size, self.num_patches), dtype=np.float32)
141 |
142 | for iteration, (input_data, indices) in \
143 | enumerate(tqdm(test_data_loader), 1):
144 | indices_np = indices.detach().numpy()
145 | input_data = input_data.to(self.device)
146 | image_encoding = self.model.encoder(input_data)
147 |
148 | if self.config['global_params']['pooling'].lower() == 'patchnetvlad':
149 | vlad_local, vlad_global = self.model.pool(image_encoding)
150 |
151 | vlad_global_pca = get_pca_encoding(self.model, vlad_global)
152 | global_feats[indices_np, :] = vlad_global_pca.detach().cpu().numpy()
153 |
154 | for this_local in vlad_local:
155 | patch_feats_batch = np.empty((this_local.size(0), pool_size, this_local.size(2)),
156 | dtype=np.float32)
157 | grid = np.indices((1, this_local.size(0)))
158 | this_local_pca = get_pca_encoding(self.model, this_local.permute(2, 0, 1).reshape(-1, this_local.size(1))).\
159 | reshape(this_local.size(2), this_local.size(0), pool_size).permute(1, 2, 0)
160 | patch_feats_batch[grid, :, :] = this_local_pca.detach().cpu().numpy()
161 | for i, val in enumerate(indices_np):
162 | patch_feats[val] = patch_feats_batch[i]
163 | else:
164 | vlad_global = self.model.pool(image_encoding)
165 | vlad_global_pca = get_pca_encoding(self.model, vlad_global)
166 | global_feats[indices_np, :] = vlad_global_pca.detach().cpu().numpy()
167 |
168 | if self.config['global_params']['pooling'].lower() == 'patchnetvlad':
169 | return global_feats, patch_feats
170 | else:
171 | return global_feats
172 |
173 |
174 | def local_matcher_from_numpy_single_scale(self, input_query_local_features_prefix, input_index_local_features_prefix):
175 | from patchnetvlad.models.local_matcher import normalise_func, calc_keypoint_centers_from_patches
176 | from patchnetvlad.tools.patch_matcher import PatchMatcher
177 |
178 | patch_sizes = [int(s) for s in self.config['global_params']['patch_sizes'].split(",")]
179 | assert(len(patch_sizes) == 1)
180 | strides = [int(s) for s in self.config['global_params']['strides'].split(",")]
181 | patch_weights = np.array(self.config['feature_match']['patchWeights2Use'].split(",")).astype(float)
182 |
183 | all_keypoints = []
184 | all_indices = []
185 |
186 | for patch_size, stride in zip(patch_sizes, strides):
187 | # we currently only provide support for square patches, but this can be easily modified for future works
188 | keypoints, indices = calc_keypoint_centers_from_patches(self.config['feature_match'], patch_size, patch_size, stride, stride)
189 | all_keypoints.append(keypoints)
190 | all_indices.append(indices)
191 |
192 | raw_diffs = []
193 |
194 | matcher = PatchMatcher(self.config['feature_match']['matcher'], patch_sizes, strides, all_keypoints, all_indices)
195 |
196 | for q_idx in tqdm(range(len(input_query_local_features_prefix)), leave=False, desc='Patch compare pred'):
197 | diffs = np.zeros((len(input_index_local_features_prefix), len(patch_sizes)))
198 | qfeat = [torch.transpose(torch.tensor(input_query_local_features_prefix[q_idx], device=self.device), 0, 1)]
199 |
200 | for candidate in range(len(input_index_local_features_prefix)):
201 | dbfeat = [torch.tensor(input_index_local_features_prefix[candidate], device=self.device)]
202 | diffs[candidate, :], _, _ = matcher.match(qfeat, dbfeat)
203 |
204 | diffs = normalise_func(diffs, len(patch_sizes), patch_weights)
205 | raw_diffs.append(diffs)
206 |
207 | return np.array(raw_diffs).T * -1
208 |
--------------------------------------------------------------------------------
/matching/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stschubert/VPR_Tutorial/e071309301f7b360449700371a69242ed65cad80/matching/__init__.py
--------------------------------------------------------------------------------
/matching/matching.py:
--------------------------------------------------------------------------------
1 | # =====================================================================
2 | # Copyright (C) 2023 Stefan Schubert, stefan.schubert@etit.tu-chemnitz.de
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 | #
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 | #
14 | # You should have received a copy of the GNU General Public License
15 | # along with this program. If not, see .
16 | # =====================================================================
17 | #
18 | import numpy as np
19 | from scipy.stats import norm
20 | from typing import Union
21 |
22 |
23 | def best_match_per_query(S: np.ndarray) -> np.ndarray:
24 | """
25 | Finds the best match per query from a similarity matrix S.
26 |
27 | Args:
28 | S (np.ndarray): A two-dimensional similarity matrix with continuous values.
29 | Higher values indicate higher similarity.
30 |
31 | Returns:
32 | np.ndarray: A two-dimensional boolean matrix with the same shape as S,
33 | where the best match per query is marked as True.
34 | """
35 | i = np.argmax(S, axis=0)
36 | j = np.int64(range(len(i)))
37 |
38 | M = np.zeros_like(S, dtype='bool')
39 | M[i, j] = True
40 |
41 | return M
42 |
43 |
44 | def thresholding(S: np.ndarray, thresh: Union[str, float]) -> np.ndarray:
45 | """
46 | Applies thresholding on a similarity matrix S based on a given threshold or
47 | an automatic thresholding method.
48 |
49 | The automatic thresholding is based on Eq. 2-4 in Schubert, S., Neubert, P. &
50 | Protzel, P. (2021). Beyond ANN: Exploiting Structural Knowledge for Efficient
51 | Place Recognition. In Proc. of Intl. Conf. on Robotics and Automation (ICRA).
52 | DOI: 10.1109/ICRA48506.2021.9561006
53 |
54 | Args:
55 | S (np.ndarray): A two-dimensional similarity matrix with continuous values.
56 | Higher values indicate higher similarity.
57 | thresh (Union[str, float]): A threshold value or the string 'auto' to apply
58 | automatic thresholding.
59 |
60 | Returns:
61 | np.ndarray: A two-dimensional boolean matrix with the same shape as S,
62 | where values greater or equal to the threshold are marked as True.
63 | """
64 | if thresh == 'auto':
65 | mu = np.median(S)
66 | sig = np.median(np.abs(S - mu)) / 0.675
67 | thresh = norm.ppf(1 - 1e-6, loc=mu, scale=sig)
68 |
69 | M = S >= thresh
70 |
71 | return M
72 |
--------------------------------------------------------------------------------
/output_images/examples_tp_fp.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stschubert/VPR_Tutorial/e071309301f7b360449700371a69242ed65cad80/output_images/examples_tp_fp.jpg
--------------------------------------------------------------------------------
/output_images/matchings.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stschubert/VPR_Tutorial/e071309301f7b360449700371a69242ed65cad80/output_images/matchings.jpg
--------------------------------------------------------------------------------
/output_images/pr_curve.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stschubert/VPR_Tutorial/e071309301f7b360449700371a69242ed65cad80/output_images/pr_curve.jpg
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | ipywidgets==8.0.6
2 | matplotlib==3.7.1
3 | numpy==1.24.3
4 | patchnetvlad==0.1.7
5 | Pillow==9.5.0
6 | scipy==1.9.1
7 | setuptools==67.7.2
8 | scikit_image==0.19.3
9 | tensorflow==2.11.1
10 | tensorflow_hub==0.12.0
11 | torch==2.0.0
12 | torchvision==0.15.1a0
13 | tqdm==4.65.0
14 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os, sys
2 | from setuptools import setup, find_packages
3 |
4 |
5 | here = os.path.abspath(os.path.dirname(__file__))
6 |
7 | # Get the long description from the README file
8 | with open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
9 | long_description = f.read()
10 |
11 |
12 | install_require_list = [
13 | 'numpy', 'matplotlib', 'Pillow', 'scipy',
14 | 'scikit-image', 'tensorflow', 'tensorflow_hub',
15 | 'torch', 'torchvision', 'tqdm']
16 |
17 | # workaround as opencv-python does not show up in "pip list" within a conda environment
18 | # we do not care as conda recipe has py-opencv requirement anyhow
19 | is_conda = os.path.exists(os.path.join(sys.prefix, 'conda-meta'))
20 | if not is_conda:
21 | install_require_list.append('opencv-python')
22 |
23 | setup(name='vpr_tutorial',
24 | version='1.0.0',
25 | description='Visual Place Recognition: A Tutorial. Code repository supplementing our paper.',
26 | long_description = long_description,
27 | long_description_content_type='text/markdown',
28 | author='Stefan Schubert, Peer Neubert, Sourav Garg, Michael Milford and Tobias Fischer',
29 | author_email='stefan.schubert@etit.tu-chemnitz.de',
30 | url='https://github.com/QVPR/Patch-NetVLAD',
31 | license='GPL-3.0-or-later',
32 | classifiers=[
33 | # 3 - Alpha
34 | # 4 - Beta
35 | # 5 - Production/Stable
36 | 'Development Status :: 4 - Beta',
37 |
38 | # Indicate who your project is intended for
39 | 'Intended Audience :: Developers',
40 | # Pick your license as you wish (should match "license" above)
41 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
42 |
43 | # Specify the Python versions you support here. In particular, ensure
44 | # that you indicate whether you support Python 2, Python 3 or both.
45 | 'Programming Language :: Python :: 3.8',
46 | 'Programming Language :: Python :: 3.9',
47 | 'Programming Language :: Python :: 3.10',
48 | 'Programming Language :: Python :: 3.11',
49 | ],
50 | python_requires='>=3.8',
51 | install_requires=install_require_list,
52 | packages=find_packages(),
53 | keywords=[
54 | 'python', 'place recognition', 'image retrieval', 'computer vision', 'robotics'
55 | ],
56 | scripts=['demo.py'],
57 | entry_points={
58 | 'console_scripts': ['vpr-tutorial-demo=demo:main',],
59 | },
60 | # package_data={'': ['configs/*.ini', 'dataset_gt_files/*.npz', 'example_images/*',
61 | # 'output_features/.hidden', 'pretrained_models/.hidden', 'results/.hidden',
62 | # 'dataset_imagenames/*.txt']}
63 | )
64 |
--------------------------------------------------------------------------------
|