├── object-locator
├── checkpoints
│ └── .gitignore
├── __init__.py
├── models
│ ├── __init__.py
│ ├── utils.py
│ ├── unet_parts.py
│ └── unet_model.py
├── __main__.py
├── make_metric_plots.py
├── paint.py
├── metrics_from_results.py
├── bmm.py
├── find_lr.py
├── logger.py
├── losses.py
├── data_plant_stuff.py
├── utils.py
├── get_image_size.py
├── locate.py
├── metrics.py
├── data.py
└── train.py
├── environment.yml
├── .gitignore
├── setup.py
├── scripts_dataset_and_results
├── parseResults.py
├── generate_csv.py
└── spacing_stats_to_csv.py
├── README.md
└── COPYRIGHT.txt
/object-locator/checkpoints/.gitignore:
--------------------------------------------------------------------------------
1 | # https://stackoverflow.com/questions/115983/how-can-i-add-an-empty-directory-to-a-git-repository#932982
2 | # Ignore everything in this directory
3 | *
4 | # Except this file
5 | !.gitignore
6 |
--------------------------------------------------------------------------------
/object-locator/__init__.py:
--------------------------------------------------------------------------------
1 | __copyright__ = \
2 | """
3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
4 | All rights reserved.
5 |
6 | This software is covered by US patents and copyright.
7 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
8 |
9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
10 |
11 | Last Modified: 10/02/2019
12 | """
13 | __license__ = "CC BY-NC-SA 4.0"
14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
15 | __version__ = "1.6.0"
16 |
--------------------------------------------------------------------------------
/object-locator/models/__init__.py:
--------------------------------------------------------------------------------
1 | __copyright__ = \
2 | """
3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
4 | All rights reserved.
5 |
6 | This software is covered by US patents and copyright.
7 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
8 |
9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
10 |
11 | Last Modified: 10/02/2019
12 | """
13 | __license__ = "CC BY-NC-SA 4.0"
14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
15 | __version__ = "1.6.0"
16 |
--------------------------------------------------------------------------------
/environment.yml:
--------------------------------------------------------------------------------
1 | name: object-locator
2 | channels:
3 | - pytorch
4 | - conda-forge
5 | - defaults
6 | dependencies:
7 | - imageio=2.3.0
8 | - ipdb=0.11
9 | - ipython=6.3.1
10 | - ipython_genutils=0.2.0
11 | - matplotlib=2.2.2
12 | - numpy=1.14.3
13 | - opencv=3.4.1
14 | - pandas=0.22.0
15 | - parse=1.8.2
16 | - pip=9.0.3
17 | - python=3.6.5
18 | - python-dateutil=2.7.2
19 | - scikit-image=0.13.1
20 | - scikit-learn=0.19.1
21 | - scipy=1.0.1
22 | - setuptools=39.1.0
23 | - tqdm=4.23.1
24 | - xmltodict=0.11.0
25 | - pytorch=1.0.0
26 | - pip:
27 | - ballpark==1.4.0
28 | - visdom==0.1.8.5
29 | - peterpy
30 | - torchvision==0.2.1
31 |
32 |
--------------------------------------------------------------------------------
/object-locator/__main__.py:
--------------------------------------------------------------------------------
1 | __copyright__ = \
2 | """
3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
4 | All rights reserved.
5 |
6 | This software is covered by US patents and copyright.
7 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
8 |
9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
10 |
11 | Last Modified: 10/02/2019
12 | """
13 | __license__ = "CC BY-NC-SA 4.0"
14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
15 | __version__ = "1.6.0"
16 |
17 | # Allow printing Unicode characters
18 | import os
19 | os.environ["PYTHONIOENCODING"] = 'UTF-8'
20 |
21 | # Execute locate.py script
22 | from . import locate as object_locator
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 | MANIFEST
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 | .static_storage/
56 | .media/
57 | local_settings.py
58 |
59 | # Flask stuff:
60 | instance/
61 | .webassets-cache
62 |
63 | # Scrapy stuff:
64 | .scrapy
65 |
66 | # Sphinx documentation
67 | docs/_build/
68 |
69 | # PyBuilder
70 | target/
71 |
72 | # Jupyter Notebook
73 | .ipynb_checkpoints
74 |
75 | # pyenv
76 | .python-version
77 |
78 | # celery beat schedule file
79 | celerybeat-schedule
80 |
81 | # SageMath parsed files
82 | *.sage.py
83 |
84 | # Environments
85 | .env
86 | .venv
87 | env/
88 | venv/
89 | ENV/
90 | env.bak/
91 | venv.bak/
92 |
93 | # Spyder project settings
94 | .spyderproject
95 | .spyproject
96 |
97 | # Rope project settings
98 | .ropeproject
99 |
100 | # mkdocs documentation
101 | /site
102 |
103 | # mypy
104 | .mypy_cache/
105 |
106 | # vim
107 | *.swp
108 |
--------------------------------------------------------------------------------
/object-locator/models/utils.py:
--------------------------------------------------------------------------------
1 | __copyright__ = \
2 | """
3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
4 | All rights reserved.
5 |
6 | This software is covered by US patents and copyright.
7 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
8 |
9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
10 |
11 | Last Modified: 10/02/2019
12 | """
13 | __license__ = "CC BY-NC-SA 4.0"
14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
15 | __version__ = "1.6.0"
16 |
17 |
18 | import h5py
19 | import torch
20 | import shutil
21 |
22 | def save_net(fname, net):
23 | with h5py.File(fname, 'w') as h5f:
24 | for k, v in net.state_dict().items():
25 | h5f.create_dataset(k, data=v.cpu().numpy())
26 | def load_net(fname, net):
27 | with h5py.File(fname, 'r') as h5f:
28 | for k, v in net.state_dict().items():
29 | param = torch.from_numpy(np.asarray(h5f[k]))
30 | v.copy_(param)
31 |
32 | def save_checkpoint(state, is_best,task_id, filename='checkpoint.pth.tar'):
33 | torch.save(state, task_id+filename)
34 | if is_best:
35 | shutil.copyfile(task_id+filename, task_id+'model_best.pth.tar')
36 |
37 |
38 | """
39 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
40 | All rights reserved.
41 |
42 | This software is covered by US patents and copyright.
43 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
44 |
45 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
46 |
47 | Last Modified: 10/02/2019
48 | """
49 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup, find_packages
2 |
3 | setup(
4 | name='object-locator',
5 | version='1.6.0',
6 | description='Object Location using PyTorch.',
7 |
8 | # The project's main homepage.
9 | url='https://engineering.purdue.edu/~sorghum',
10 |
11 | # Author details
12 | author='Javier Ribera, David Guera, Yuhao Chen, and Edward J. Delp',
13 | author_email='ace@ecn.purdue.edu',
14 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers
15 | classifiers=[
16 | # How mature is this project? Common values are
17 | # 3 - Alpha
18 | # 4 - Beta
19 | # 5 - Production/Stable
20 | 'Development Status :: 4 - Beta',
21 |
22 | # Specify the Python versions you support here. In particular, ensure
23 | # that you indicate whether you support Python 2, Python 3 or both.
24 | 'Programming Language :: Python :: 3.6',
25 | ],
26 | python_requires='~=3.6',
27 | # What does your project relate to?
28 | keywords='object localization location purdue',
29 |
30 | # You can just specify the packages manually here if your project is
31 | # simple. Or you can use find_packages().
32 | packages=['object-locator', 'object-locator.models'],
33 | package_dir={'object-locator': 'object-locator'},
34 |
35 | package_data={'object-locator': ['checkpoints/*.ckpt',
36 | '../COPYRIGHT.txt',
37 | '../README.md']},
38 | include_package_data=True,
39 |
40 | # List run-time dependencies here. These will be installed by pip when
41 | # your project is installed. For an analysis of "install_requires" vs pip's
42 | # requirements files see:
43 | # https://packaging.python.org/en/latest/requirements.html
44 | # (We actually use conda for dependency management)
45 | # install_requires=['matplotlib', 'numpy',
46 | # 'scikit-image', 'tqdm', 'argparse', 'parse',
47 | # 'scikit-learn', 'pandas'],
48 | )
49 |
--------------------------------------------------------------------------------
/object-locator/make_metric_plots.py:
--------------------------------------------------------------------------------
1 | __copyright__ = \
2 | """
3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
4 | All rights reserved.
5 |
6 | This software is covered by US patents and copyright.
7 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
8 |
9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
10 |
11 | Last Modified: 10/02/2019
12 | """
13 | __license__ = "CC BY-NC-SA 4.0"
14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
15 | __version__ = "1.6.0"
16 |
17 |
18 | import os
19 | import numpy as np
20 | import pandas as pd
21 | import argparse
22 |
23 | from . import metrics
24 |
25 | # Parse command-line arguments
26 | parser = argparse.ArgumentParser(
27 | description='Create a bunch of plot from the metrics in a CSV.',
28 | formatter_class=argparse.ArgumentDefaultsHelpFormatter)
29 | parser.add_argument('csv',
30 | help='CSV file with the precision and recall results.')
31 | parser.add_argument('out',
32 | help='Output directory.')
33 | parser.add_argument('--title',
34 | default='',
35 | help='Title of the plot in the figure.')
36 | parser.add_argument('--taus',
37 | type=str,
38 | required=True,
39 | help='Detection threshold taus. '
40 | 'For each of these taus, a precision(r) and recall(r) will be created.'
41 | 'The closest to these values will be used.')
42 | parser.add_argument('--radii',
43 | type=str,
44 | required=True,
45 | help='List of values, each with different colors in the scatter plot. '
46 | 'Maximum distance to consider a True Positive. '
47 | 'The closest to this value will be used.')
48 | args = parser.parse_args()
49 |
50 |
51 | os.makedirs(args.out, exist_ok=True)
52 |
53 | taus = [float(tau) for tau in args.taus.replace('[', '').replace(']', '').split(',')]
54 | radii = [int(r) for r in args.radii.replace('[', '').replace(']', '').split(',')]
55 |
56 | figs = metrics.make_metric_plots(csv_path=args.csv,
57 | taus=taus,
58 | radii=radii,
59 | title=args.title)
60 |
61 | for label, fig in figs.items():
62 | # Save to disk
63 | fig.savefig(os.path.join(args.out, f'{label}.png'))
64 |
65 |
66 | """
67 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
68 | All rights reserved.
69 |
70 | This software is covered by US patents and copyright.
71 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
72 |
73 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
74 |
75 | Last Modified: 10/02/2019
76 | """
77 |
--------------------------------------------------------------------------------
/object-locator/paint.py:
--------------------------------------------------------------------------------
1 | from __future__ import print_function
2 |
3 | __copyright__ = \
4 | """
5 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
6 | All rights reserved.
7 |
8 | This software is covered by US patents and copyright.
9 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
10 |
11 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
12 |
13 | Last Modified: 10/02/2019
14 | """
15 | __license__ = "CC BY-NC-SA 4.0"
16 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
17 | __version__ = "1.6.0"
18 |
19 |
20 | import os
21 | import sys
22 |
23 | import cv2
24 | from tqdm import tqdm
25 | import numpy as np
26 | import torch
27 | from torchvision import transforms
28 | from torch.utils import data
29 |
30 | from .data import CSVDataset
31 | from .data import csv_collator
32 | from . import argparser
33 | from . import utils
34 |
35 |
36 | # Parse command line arguments
37 | args = argparser.parse_command_args('testing')
38 |
39 | # Tensor type to use, select CUDA or not
40 | torch.set_default_dtype(torch.float32)
41 | device_cpu = torch.device('cpu')
42 |
43 | # Set seeds
44 | np.random.seed(args.seed)
45 | torch.manual_seed(args.seed)
46 | if args.cuda:
47 | torch.cuda.manual_seed_all(args.seed)
48 |
49 | # Data loading code
50 | try:
51 | testset = CSVDataset(args.dataset,
52 | transforms=transforms.Compose([
53 | transforms.ToTensor(),
54 | ]),
55 | max_dataset_size=args.max_testset_size)
56 | except ValueError as e:
57 | print(f'E: {e}')
58 | exit(-1)
59 | dataset_loader = data.DataLoader(testset,
60 | batch_size=1,
61 | num_workers=args.nThreads,
62 | collate_fn=csv_collator)
63 |
64 | os.makedirs(os.path.join(args.out), exist_ok=True)
65 |
66 | for img, dictionary in tqdm(dataset_loader):
67 |
68 | # Move to device
69 | img = img.to(device_cpu)
70 |
71 | # One image at a time (BS=1)
72 | img = img[0]
73 | dictionary = dictionary[0]
74 |
75 | # Tensor -> float & numpy
76 | target_locs = dictionary['locations'].to(device_cpu).numpy().reshape(-1, 2)
77 | img = img.to(device_cpu).numpy()
78 |
79 | img *= 255
80 |
81 | # Paint circles on top of image
82 | img_with_x = utils.paint_circles(img=img,
83 | points=target_locs,
84 | color='white')
85 | img_with_x = np.moveaxis(img_with_x, 0, 2)
86 | img_with_x = img_with_x[:, :, ::-1]
87 |
88 | cv2.imwrite(os.path.join(args.out, dictionary['filename']),
89 | img_with_x)
90 |
91 |
92 | """
93 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
94 | All rights reserved.
95 |
96 | This software is covered by US patents and copyright.
97 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
98 |
99 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
100 |
101 | Last Modified: 10/02/2019
102 | """
103 |
--------------------------------------------------------------------------------
/scripts_dataset_and_results/parseResults.py:
--------------------------------------------------------------------------------
1 | __copyright__ = \
2 | """
3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
4 | All rights reserved.
5 |
6 | This software is covered by US patents and copyright.
7 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
8 |
9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
10 |
11 | Last Modified: 10/02/2019
12 | """
13 | __license__ = "CC BY-NC-SA 4.0"
14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
15 | __version__ = "1.6.0"
16 |
17 |
18 | import pandas as pd
19 | import numpy as np
20 | import sys
21 | import ast
22 | import cv2
23 | from sklearn.cluster import KMeans
24 | from sklearn.metrics.pairwise import pairwise_distances
25 | from sklearn import mixture
26 |
27 | CSV_FILE = "estimations.csv"
28 |
29 | def eval_plant_locations(estimated, gt):
30 | """
31 | Distance function between the estimated plant locations and the ground
32 | truth.
33 | This function is a symmetric function which parameter is the estimated
34 | plant locations and which is the ground truth should not matter.
35 | The returned value is guaranteed to be always positive,
36 | and is only zero if both lists are exactly equal.
37 |
38 | :param estimated: List of (x, y) or (y,x) plant locations.
39 | :param gt: List of (x, y) or (y, x) plant locations.
40 | :return: Distance between two sets.
41 | """
42 |
43 | estimated = np.array(estimated)
44 | gt = np.array(gt)
45 |
46 | # Check dimension
47 | assert estimated.ndim == gt.ndim == 2, \
48 | 'Both estimated and GT plant locations must be 2D, i.e, (x, y) or (y, x)'
49 |
50 | d2_matrix = pairwise_distances(estimated, gt, metric='euclidean')
51 |
52 | res = np.average(np.min(d2_matrix, axis=0)) + \
53 | np.average(np.min(d2_matrix, axis=1))
54 |
55 | return res
56 |
57 | def processImg(image, n, GMM=False):
58 | #extract mask from the image
59 | mask = cv2.inRange(image, (5,5,5), (255,255,255))
60 | coord = np.where(mask > 0)
61 | y = coord[0].reshape((-1, 1))
62 | x = coord[1].reshape((-1, 1))
63 |
64 | c = np.concatenate((y, x), axis=1)
65 |
66 | if GMM:
67 | gmm = mixture.GaussianMixture(n_components=n, n_init=1, covariance_type='full').fit(c)
68 | return gmm.means_.astype(np.int)
69 |
70 | else:
71 |
72 | #find kmean cluster
73 | kmeans = KMeans(n_clusters=n, random_state=0).fit(c)
74 | return kmeans.cluster_centers_
75 |
76 | def processCSV(csvfile):
77 |
78 | df = pd.read_csv(csvfile)
79 | res_array = []
80 | for i in range(len(df.iloc[:])):
81 | filename = df.iloc[:, 1][i]
82 |
83 | plant_count = df.iloc[:, 2][i]
84 | plant_count = float(plant_count.split('\n')[1].strip())
85 |
86 | gt = df.iloc[:, 3][i]
87 | gt = ast.literal_eval(gt)
88 |
89 | image = cv2.imread(filename)
90 | detected = processImg(image, int(plant_count), GMM=True)
91 |
92 | res = eval_plant_locations(detected, gt)
93 | res_array.append(res)
94 | print(res)
95 | break
96 | return res_array
97 |
98 |
99 | #Note the script needs to be put into the data directory with the CSV file
100 | res = processCSV(CSV_FILE)
101 |
102 |
103 | """
104 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
105 | All rights reserved.
106 |
107 | This software is covered by US patents and copyright.
108 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
109 |
110 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
111 |
112 | Last Modified: 10/02/2019
113 | """
114 |
--------------------------------------------------------------------------------
/object-locator/models/unet_parts.py:
--------------------------------------------------------------------------------
1 | __copyright__ = \
2 | """
3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
4 | All rights reserved.
5 |
6 | This software is covered by US patents and copyright.
7 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
8 |
9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
10 |
11 | Last Modified: 10/02/2019
12 | """
13 | __license__ = "CC BY-NC-SA 4.0"
14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
15 | __version__ = "1.6.0"
16 |
17 |
18 | # sub-parts of the U-Net model
19 |
20 | import math
21 | import warnings
22 |
23 | import torch
24 | import torch.nn as nn
25 | import torch.nn.functional as F
26 |
27 |
28 | class double_conv(nn.Module):
29 | def __init__(self, in_ch, out_ch, normaliz=True, activ=True):
30 | super(double_conv, self).__init__()
31 |
32 | ops = []
33 | ops += [nn.Conv2d(in_ch, out_ch, 3, padding=1)]
34 | # ops += [nn.Dropout(p=0.1)]
35 | if normaliz:
36 | ops += [nn.BatchNorm2d(out_ch)]
37 | if activ:
38 | ops += [nn.ReLU(inplace=True)]
39 | ops += [nn.Conv2d(out_ch, out_ch, 3, padding=1)]
40 | # ops += [nn.Dropout(p=0.1)]
41 | if normaliz:
42 | ops += [nn.BatchNorm2d(out_ch)]
43 | if activ:
44 | ops += [nn.ReLU(inplace=True)]
45 |
46 | self.conv = nn.Sequential(*ops)
47 |
48 | def forward(self, x):
49 | x = self.conv(x)
50 | return x
51 |
52 |
53 | class inconv(nn.Module):
54 | def __init__(self, in_ch, out_ch):
55 | super(inconv, self).__init__()
56 | self.conv = double_conv(in_ch, out_ch)
57 |
58 | def forward(self, x):
59 | x = self.conv(x)
60 | return x
61 |
62 |
63 | class down(nn.Module):
64 | def __init__(self, in_ch, out_ch, normaliz=True):
65 | super(down, self).__init__()
66 | self.mpconv = nn.Sequential(
67 | nn.MaxPool2d(2),
68 | double_conv(in_ch, out_ch, normaliz=normaliz)
69 | )
70 |
71 | def forward(self, x):
72 | x = self.mpconv(x)
73 | return x
74 |
75 |
76 | class up(nn.Module):
77 | def __init__(self, in_ch, out_ch, normaliz=True, activ=True):
78 | super(up, self).__init__()
79 | self.up = nn.Upsample(scale_factor=2,
80 | mode='bilinear',
81 | align_corners=True)
82 | # self.up = nn.ConvTranspose2d(in_ch, out_ch, 2, stride=2)
83 | self.conv = double_conv(in_ch, out_ch,
84 | normaliz=normaliz, activ=activ)
85 |
86 | def forward(self, x1, x2):
87 | with warnings.catch_warnings():
88 | warnings.simplefilter("ignore") # Upsample is deprecated
89 | x1 = self.up(x1)
90 | diffY = x2.size()[2] - x1.size()[2]
91 | diffX = x2.size()[3] - x1.size()[3]
92 | x1 = F.pad(x1, (diffX // 2, int(math.ceil(diffX / 2)),
93 | diffY // 2, int(math.ceil(diffY / 2))))
94 | x = torch.cat([x2, x1], dim=1)
95 | x = self.conv(x)
96 | return x
97 |
98 |
99 | class outconv(nn.Module):
100 | def __init__(self, in_ch, out_ch):
101 | super(outconv, self).__init__()
102 | self.conv = nn.Conv2d(in_ch, out_ch, 1)
103 | # self.conv = nn.Sequential(
104 | # nn.Conv2d(in_ch, out_ch, 1),
105 | # )
106 |
107 | def forward(self, x):
108 | x = self.conv(x)
109 | return x
110 |
111 |
112 | """
113 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
114 | All rights reserved.
115 |
116 | This software is covered by US patents and copyright.
117 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
118 |
119 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
120 |
121 | Last Modified: 10/02/2019
122 | """
123 |
--------------------------------------------------------------------------------
/scripts_dataset_and_results/generate_csv.py:
--------------------------------------------------------------------------------
1 | __copyright__ = \
2 | """
3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
4 | All rights reserved.
5 |
6 | This software is covered by US patents and copyright.
7 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
8 |
9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
10 |
11 | Last Modified: 10/02/2019
12 | """
13 | __license__ = "CC BY-NC-SA 4.0"
14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
15 | __version__ = "1.6.0"
16 |
17 |
18 | import pandas as pd
19 | import cv2
20 | import numpy as np
21 | import sys
22 | import os
23 | import ast
24 | import random
25 | import shutil
26 | from tqdm import tqdm
27 |
28 | np.random.seed(0)
29 |
30 | train_df = pd.DataFrame(columns=['plant_count'])
31 | test_df = pd.DataFrame(columns=['plant_count'])
32 | validate_df = pd.DataFrame(columns=['plant_count'])
33 |
34 | if not os.path.exists('train'):
35 | os.makedirs('train')
36 | if not os.path.exists('test'):
37 | os.makedirs('test')
38 | if not os.path.exists('validate'):
39 | os.makedirs('validate')
40 |
41 | dirs = [i for i in range(1, 18)]
42 | dirs.pop(11)
43 |
44 | filecounter = 0
45 | for dirnum in dirs:
46 | dirname = 'dataset' + str(dirnum).zfill(2)
47 |
48 | fd = open(os.path.join(dirname,'gt.txt'))
49 |
50 | data = []
51 | for line in fd:
52 | line = line.strip()
53 | imgnum = line.split(' ')[1]
54 | x = line.split(' ')[2]
55 | if (x == 'X'):
56 | continue
57 | y = line.split(' ')[3]
58 |
59 | imagename = imgnum.zfill(10)+'.png'
60 | if not os.path.exists(os.path.join(dirname,imagename)):
61 | continue
62 | image = cv2.imread(os.path.join(dirname,imagename))
63 |
64 | h = image.shape[0]
65 | x = int(x)/2
66 | y = h - int(y)/2
67 | data.append([imagename, y, x])
68 |
69 | #print(imagename)
70 | #print(x, y)
71 |
72 | random.shuffle(data)
73 | for i in range(len(data)):
74 | item = data[i]
75 | imagename = item[0]
76 | y = item[1]
77 | x = item[2]
78 |
79 | # newname = str(filecounter).zfill(10) + '.png'
80 | newname = dirname + '_' + imagename
81 | df = pd.DataFrame(data=[[1, [[y, x]]]],
82 | index=[newname],
83 | columns=['plant_count', 'plant_locations'])
84 | if (i < len(data)*0.8):
85 | if os.path.isfile('train/'+newname):
86 | print('%s exists' % 'train/'+newname)
87 | exit(-1)
88 | shutil.move(os.path.join(dirname,imagename), 'train/'+newname)
89 | train_df = train_df.append(df)
90 | elif (i < len(data)*0.9):
91 | if os.path.isfile('train/'+newname):
92 | print('%s exists' % 'test/'+newname)
93 | exit(-1)
94 | shutil.move(os.path.join(dirname,imagename), 'test/'+newname)
95 | test_df = test_df.append(df)
96 | else:
97 | if os.path.isfile('train/'+newname):
98 | print('%s exists' % 'test/'+newname)
99 | exit(-1)
100 | shutil.move(os.path.join(dirname,imagename), 'validate/'+newname)
101 | validate_df = validate_df.append(df)
102 |
103 | train_df.to_csv('train.csv')
104 | shutil.move('train.csv', 'train')
105 | test_df.to_csv('test.csv')
106 | shutil.move('test.csv', 'test')
107 | validate_df.to_csv('validate.csv')
108 | shutil.move('validate.csv', 'validate')
109 |
110 |
111 | """
112 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
113 | All rights reserved.
114 |
115 | This software is covered by US patents and copyright.
116 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
117 |
118 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
119 |
120 | Last Modified: 10/02/2019
121 | """
122 |
--------------------------------------------------------------------------------
/object-locator/metrics_from_results.py:
--------------------------------------------------------------------------------
1 | __copyright__ = \
2 | """
3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
4 | All rights reserved.
5 |
6 | This software is covered by US patents and copyright.
7 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
8 |
9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
10 |
11 | Last Modified: 10/02/2019
12 | """
13 | __license__ = "CC BY-NC-SA 4.0"
14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
15 | __version__ = "1.6.0"
16 |
17 |
18 | import os
19 | import argparse
20 | import ast
21 | import math
22 |
23 | from tqdm import tqdm
24 | import numpy as np
25 | import pandas as pd
26 |
27 | from . import metrics
28 | from . import get_image_size
29 |
30 | # Parse command-line arguments
31 | parser = argparse.ArgumentParser(
32 | description='Compute metrics from results and GT.',
33 | formatter_class=argparse.ArgumentDefaultsHelpFormatter)
34 | required_args = parser.add_argument_group('MANDATORY arguments')
35 | optional_args = parser._action_groups.pop()
36 | required_args.add_argument('results',
37 | help='Input CSV file with the estimated locations.')
38 | required_args.add_argument('gt',
39 | help='Input CSV file with the groundtruthed locations.')
40 | required_args.add_argument('metrics',
41 | help='Output CSV file with the metrics '
42 | '(MAE, AHD, Precision, Recall...)')
43 | required_args.add_argument('--dataset',
44 | type=str,
45 | required=True,
46 | help='Dataset directory with the images. '
47 | 'This is used only to get the image diagonal, '
48 | 'as the worst estimate for the AHD.')
49 | optional_args.add_argument('--radii',
50 | type=str,
51 | default=range(0, 15 + 1),
52 | metavar='Rs',
53 | help='Detections at dist <= R to a GT pt are True Positives.')
54 | args = parser.parse_args()
55 |
56 |
57 | # Prepare Judges that will compute P/R as fct of r and th
58 | judges = [metrics.Judge(r=r) for r in args.radii]
59 |
60 | df_results = pd.read_csv(args.results)
61 | df_gt = pd.read_csv(args.gt)
62 |
63 | df_metrics = pd.DataFrame(columns=['r',
64 | 'precision', 'recall', 'fscore', 'MAHD',
65 | 'MAPE', 'ME', 'MPE', 'MAE',
66 | 'MSE', 'RMSE', 'r', 'R2'])
67 |
68 | for j, judge in enumerate(tqdm(judges)):
69 |
70 | for idx, row_result in df_results.iterrows():
71 | filename = row_result['filename']
72 | row_gt = df_gt[df_gt['filename'] == filename].iloc()[0]
73 |
74 | w, h = get_image_size.get_image_size(os.path.join(args.dataset, filename))
75 | diagonal = math.sqrt(w**2 + h**2)
76 |
77 | judge.feed_count(row_result['count'],
78 | row_gt['count'])
79 | judge.feed_points(ast.literal_eval(row_result['locations']),
80 | ast.literal_eval(row_gt['locations']),
81 | max_ahd=diagonal)
82 |
83 | df = pd.DataFrame(data=[[judge.r,
84 | judge.precision,
85 | judge.recall,
86 | judge.fscore,
87 | judge.mahd,
88 | judge.mape,
89 | judge.me,
90 | judge.mpe,
91 | judge.mae,
92 | judge.mse,
93 | judge.rmse,
94 | judge.pearson_corr \
95 | if not np.isnan(judge.pearson_corr) else 1,
96 | judge.coeff_of_determination]],
97 | columns=['r',
98 | 'precision', 'recall', 'fscore', 'MAHD',
99 | 'MAPE', 'ME', 'MPE', 'MAE',
100 | 'MSE', 'RMSE', 'r', 'R2'],
101 | index=[j])
102 | df.index.name = 'idx'
103 | df_metrics = df_metrics.append(df)
104 |
105 | # Write CSV of metrics to disk
106 | df_metrics.to_csv(args.metrics)
107 |
108 |
109 | """
110 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
111 | All rights reserved.
112 |
113 | This software is covered by US patents and copyright.
114 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
115 |
116 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
117 |
118 | Last Modified: 10/02/2019
119 | """
120 |
--------------------------------------------------------------------------------
/object-locator/bmm.py:
--------------------------------------------------------------------------------
1 | """
2 | Code from paper
3 | "A hybrid parameter estimation algorithm for beta mixtures
4 | and applications to methylation state classification"
5 | https://doi.org/10.1186/s13015-017-0112-1
6 | https://bitbucket.org/genomeinformatics/betamix
7 | """
8 |
9 | import numpy as np
10 |
11 | from itertools import count
12 | from argparse import ArgumentParser
13 |
14 | import numpy as np
15 | from scipy.stats import beta
16 |
17 |
18 | def _get_values(x, left, right):
19 | y = x[np.logical_and(x>=left, x<=right)]
20 | n = len(y)
21 | if n == 0:
22 | m = (left+right) / 2.0
23 | v = (right-left) / 12.0
24 | else:
25 | m = np.mean(y)
26 | v = np.var(y)
27 | if v == 0.0:
28 | v = (right-left) / (12.0*(n+1))
29 | return m, v, n
30 |
31 |
32 | def get_initialization(x, ncomponents, limit=0.8):
33 | # TODO: work with specific components instead of just their number
34 | points = np.linspace(0.0, 1.0, ncomponents+2)
35 | means = np.zeros(ncomponents)
36 | variances = np.zeros(ncomponents)
37 | pi = np.zeros(ncomponents)
38 | # init first component
39 | means[0], variances[0], pi[0] = _get_values(x, points[0], points[1])
40 | # init intermediate components
41 | N = ncomponents - 1
42 | for j in range(1, N):
43 | means[j], variances[j], pi[j] = _get_values(x, points[j], points[j+2])
44 | # init last component
45 | means[N], variances[N], pi[N] = _get_values(x, points[N+1], points[N+2])
46 |
47 | # compute parameters ab, pi
48 | ab = [ab_from_mv(m,v) for (m,v) in zip(means,variances)]
49 | pi = pi / pi.sum()
50 |
51 | # adjust first and last
52 | if ab[0][0] >= limit: ab[0] = (limit, ab[0][1])
53 | if ab[-1][1] >= limit: ab[-1] = (ab[-1][0], limit)
54 | return ab, pi
55 |
56 |
57 | def ab_from_mv(m, v):
58 | """
59 | estimate beta parameters (a,b) from given mean and variance;
60 | return (a,b).
61 |
62 | Note, for uniform distribution on [0,1], (m,v)=(0.5,1/12)
63 | """
64 | phi = m*(1-m)/v - 1 # z = 2 for uniform distribution
65 | return (phi*m, phi*(1-m)) # a = b = 1 for uniform distribution
66 |
67 |
68 | def get_weights(x, ab, pi):
69 | """return nsamples X ncomponents matrix with association weights"""
70 | bpdf = beta.pdf
71 | n, c = len(x), len(ab)
72 | y = np.zeros((n,c), dtype=float)
73 | s = np.zeros((n,1), dtype=float)
74 | for (j, p,(a,b)) in zip(count(), pi, ab):
75 | y[:,j] = p * bpdf(x, a, b)
76 | s = np.sum(y,1).reshape((n,1))
77 | with np.warnings.catch_warnings():
78 | np.warnings.filterwarnings('ignore')
79 | w = y / s # this may produce inf or nan; this is o.k.!
80 | # clean up weights w, remove infs, nans, etc.
81 | wfirst = np.array([1] + [0]*(c-1), dtype=float)
82 | wlast = np.array([0]*(c-1) + [1], dtype=float)
83 | bad = (~np.isfinite(w)).any(axis=1)
84 | badfirst = np.logical_and(bad, x<0.5)
85 | badlast = np.logical_and(bad, x>=0.5)
86 | w[badfirst,:] = wfirst
87 | w[badlast,:] = wlast
88 | # now all weights are valid finite values and sum to 1 for each row
89 | assert np.all(np.isfinite(w)), (w, np.isfinite(w))
90 | assert np.allclose(np.sum(w,1), 1.0), np.max(np.abs(np.sum(w,1)-1.0))
91 | return w
92 |
93 |
94 | def relerror(x,y):
95 | if x==y: return 0.0
96 | return abs(x-y)/max(abs(x),abs(y))
97 |
98 | def get_delta(ab, abold, pi, piold):
99 | epi = max(relerror(p,po) for (p,po) in zip(pi,piold))
100 | ea = max(relerror(a,ao) for (a,_), (ao,_) in zip(ab,abold))
101 | eb = max(relerror(b,bo) for (_,b), (_,bo) in zip(ab,abold))
102 | return max(epi,ea,eb)
103 |
104 |
105 | def estimate_mixture(x, init, steps=1000, tolerance=1E-5):
106 | """
107 | estimate a beta mixture model from the given data x
108 | with the given number of components and component types
109 | """
110 | (ab, pi) = init
111 | n, ncomponents = len(x), len(ab)
112 |
113 | for step in count():
114 | if step >= steps:
115 | break
116 | abold = list(ab)
117 | piold = pi[:]
118 | # E-step: compute component memberships for each x
119 | w = get_weights(x, ab, pi)
120 | # compute component means and variances and parameters
121 | for j in range(ncomponents):
122 | wj = w[:,j]
123 | pij = np.sum(wj)
124 | m = np.dot(wj,x) / pij
125 | v = np.dot(wj,(x-m)**2) / pij
126 | if np.isnan(m) or np.isnan(v):
127 | m = 0.5; v = 1/12 # uniform
128 | ab[j]=(1,1) # uniform
129 | assert pij == 0.0
130 | else:
131 | assert np.isfinite(m) and np.isfinite(v), (j,m,v,pij)
132 | ab[j] = ab_from_mv(m,v)
133 | pi[j] = pij / n
134 | delta = get_delta(ab, abold, pi, piold)
135 | if delta < tolerance:
136 | break
137 | usedsteps = step + 1
138 | return (ab, pi, usedsteps)
139 |
140 |
141 | def estimate(x, components, steps=1000, tolerance=1E-4):
142 | init = get_initialization(x, len(components))
143 | (ab, pi, usedsteps) = estimate_mixture(x, init, steps=steps, tolerance=tolerance)
144 | return (ab, pi, usedsteps)
145 |
146 |
147 | class AccumHistogram1D():
148 | """https://raw.githubusercontent.com/NichtJens/numpy-accumulative-histograms/master/accuhist.py"""
149 |
150 | def __init__(self, nbins, xlow, xhigh):
151 | self.nbins = nbins
152 | self.xlow = xlow
153 | self.xhigh = xhigh
154 |
155 | self.range = (xlow, xhigh)
156 |
157 | self.hist, edges = np.histogram([], bins=nbins, range=self.range)
158 | self.bins = (edges[:-1] + edges[1:]) / 2.
159 |
160 | def fill(self, arr):
161 | hist, _ = np.histogram(arr, bins=self.nbins, range=self.range)
162 | self.hist += hist
163 |
164 | @property
165 | def data(self):
166 | return self.bins, self.hist
167 |
168 |
169 |
--------------------------------------------------------------------------------
/scripts_dataset_and_results/spacing_stats_to_csv.py:
--------------------------------------------------------------------------------
1 | __copyright__ = \
2 | """
3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
4 | All rights reserved.
5 |
6 | This software is covered by US patents and copyright.
7 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
8 |
9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
10 |
11 | Last Modified: 10/02/2019
12 | """
13 | __license__ = "CC BY-NC-SA 4.0"
14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp"
15 | __version__ = "1.6.0"
16 |
17 |
18 | import argparse
19 | import os
20 | import pandas as pd
21 | from tqdm import tqdm
22 | from scipy.spatial.distance import euclidean as distance
23 | import statistics
24 | import matplotlib.mlab as mlab
25 | import matplotlib.pyplot as plt
26 | import numpy as np
27 |
28 | if __name__ == '__main__':
29 | # Parse command-line arguments
30 | parser = argparse.ArgumentParser(
31 | description='Compute intra-row spacing stats of a CSV. '
32 | 'Add mean, median, and stdev of each row. '
33 | 'Optional: plot histograms',
34 | formatter_class=argparse.ArgumentDefaultsHelpFormatter)
35 | parser.add_argument('in_csv',
36 | help='Input CSV with plant location info.')
37 | parser.add_argument('out_csv',
38 | help='Output CSV with the added stats.')
39 | parser.add_argument('--hist',
40 | metavar='DIR',
41 | help='Directory with histograms.')
42 | parser.add_argument('--res',
43 | metavar='DIR',
44 | type=float,
45 | default=1,
46 | help='Resolution in centimeters.')
47 | args = parser.parse_args()
48 |
49 | # Import GT from CSV
50 | df = pd.read_csv(args.in_csv)
51 |
52 | # Store stats of each single-row plot
53 | means, medians, stds = [], [], []
54 |
55 | for idx, row in tqdm(df.iterrows(), total=len(df.index)):
56 | if row['locations_wrt_orthophoto'] is np.nan:
57 | continue
58 | locs = eval(row['locations_wrt_orthophoto'])
59 |
60 | # 1. Sort by row coordinate
61 | locs = sorted(locs, key=lambda x: x[0])
62 |
63 | # 2. Compute distances (chain-like) between plants
64 | dists = list(map(distance, locs[:-1], locs[1:]))
65 |
66 | # 3. pixels -> centimeters
67 | dists = [d * args.res for d in dists]
68 |
69 | # 4. Statistics!
70 | mean = statistics.mean(dists)
71 | median = statistics.median(dists)
72 | std = statistics.stdev(dists)
73 | means.append(mean)
74 | medians.append(median)
75 | stds.append(std)
76 |
77 | # 5. Put in CSV
78 | df.loc[idx, 'mean_intrarow_spacing_in_cm'] = mean
79 | df.loc[idx, 'median_intrarow_spacing_in_cm'] = median
80 | df.loc[idx, 'stdev_intrarow_spacing_in_cm'] = std
81 |
82 | # Save to disk as CSV
83 | df.to_csv(args.out_csv)
84 |
85 | if args.hist is not None:
86 | os.makedirs(args.hist, exist_ok=True)
87 |
88 | # 6. Generate nice graphs for presentation
89 | # Means
90 | fig = plt.figure()
91 | n, bins, patches = plt.hist(
92 | means, 30, normed=1, facecolor='green', alpha=0.75, label='Histogram')
93 | # add a 'best fit' norm line
94 | y = mlab.normpdf(bins, statistics.mean(means), statistics.stdev(means))
95 | l = plt.plot(bins, y, 'r--', linewidth=1, label='Fitted Gaussian')
96 | plt.xlabel('Average intra-row spacing [cm]')
97 | plt.ylabel('Probability')
98 | plt.title('Histogram of average intra-row spacing')
99 | plt.axis([5, 30, 0, 0.3])
100 | plt.grid(True)
101 | plt.legend()
102 | fig.savefig(os.path.join(
103 | args.hist, 'histogram_averages.png'), dpi=fig.dpi)
104 |
105 | # Medians
106 | fig = plt.figure()
107 | n, bins, patches = plt.hist(
108 | medians, 30, normed=1, facecolor='green', alpha=0.75, label='Histogram')
109 | # add a 'best fit' norm line
110 | y = mlab.normpdf(bins, statistics.mean(
111 | medians), statistics.stdev(medians))
112 | l = plt.plot(bins, y, 'r--', linewidth=1, label='Fitted Gaussian')
113 | plt.xlabel('Median of intra-row spacing [cm]')
114 | plt.ylabel('Probability')
115 | plt.title('Histogram of medians intra-row spacing')
116 | plt.axis([5, 30, 0, 0.3])
117 | plt.grid(True)
118 | plt.legend()
119 | fig.savefig(os.path.join(
120 | args.hist, 'histogram_medians.png'), dpi=fig.dpi)
121 |
122 | # Standard deviations
123 | fig = plt.figure()
124 | n, bins, patches = plt.hist(
125 | stds, 30, normed=1, facecolor='green', alpha=0.75, label='Histogram')
126 | # add a 'best fit' norm line
127 | y = mlab.normpdf(bins, statistics.mean(stds), statistics.stdev(stds))
128 | l = plt.plot(bins, y, 'r--', linewidth=1, label='Fitted Gaussian')
129 | plt.xlabel('Standard deviation of intra-row spacing [cm]')
130 | plt.ylabel('Probability')
131 | plt.title('Histogram of standard deviations of intra-row spacing')
132 | plt.axis([0, 25, 0, 0.3])
133 | plt.grid(True)
134 | plt.legend()
135 | fig.savefig(os.path.join(
136 | args.hist, 'histogram_stdevs.png'), dpi=fig.dpi)
137 |
138 |
139 | """
140 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation.
141 | All rights reserved.
142 |
143 | This software is covered by US patents and copyright.
144 | This source code is to be used for academic research purposes only, and no commercial use is allowed.
145 |
146 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University.
147 |
148 | Last Modified: 10/02/2019
149 | """
150 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Locating Objects Without Bounding Boxes
2 | PyTorch code for "Locating Objects Without Bounding Boxes" , CVPR 2019 - Oral, Best Paper Finalist (Top 1 %) [[Paper]](http://openaccess.thecvf.com/content_CVPR_2019/html/Ribera_Locating_Objects_Without_Bounding_Boxes_CVPR_2019_paper.html) [[Youtube]](https://youtu.be/8qkrPSjONhA?t=2620)
3 |
4 |
5 |
6 |
7 |
8 | ## Citing this work
9 | ```
10 | @article{ribera2019,
11 | title={Locating Objects Without Bounding Boxes},
12 | author={Javier Ribera and David G\"{u}era and Yuhao Chen and Edward J. Delp},
13 | journal={Proceedings of the Computer Vision and Pattern Recognition (CVPR)},
14 | month={June},
15 | year={2019},
16 | note={{Long Beach, CA}}
17 | }
18 | ```
19 |
20 | ## Datasets
21 | The datasets used in the paper can be downloaded from:
22 | - [Mall dataset](http://personal.ie.cuhk.edu.hk/~ccloy/downloads_mall_dataset.html)
23 | - [Pupil dataset](http://www.ti.uni-tuebingen.de/Pupil-detection.1827.0.html)
24 | - [Plant dataset](https://engineering.purdue.edu/~sorghum/dataset-plant-centers-2016)
25 |
26 | ## Installation
27 | Use conda to recreate the environment provided with the code:
28 |
29 | conda env create -f environment.yml 30 |31 | 32 | Activate the environment: 33 |
34 | conda activate object-locator 35 |36 | 37 | Install the tool: 38 |
39 | pip install . 40 |41 | (do not forget the period) 42 | 43 | ## Usage 44 | If you are only interested in the code of the Weighted Hausdorff Distance (which is the loss used in the paper and the main contribution), you can just get the [losses.py](object-locator/losses.py) file. If you want to use the entire object location tool: 45 | 46 | Activate the environment: 47 |
48 | conda activate object-locator 49 |50 | 51 | Run this to get help (usage instructions): 52 |
53 | python -m object-locator.locate -h 54 | python -m object-locator.train -h 55 |56 | 57 | Example: 58 | 59 |
60 | python -m object-locator.locate \ 61 | --dataset DIRECTORY \ 62 | --out DIRECTORY \ 63 | --model CHECKPOINTS \ 64 | --evaluate \ 65 | --no-gpu \ 66 | --radius 5 67 |68 | 69 |
70 | python -m object-locator.train \ 71 | --train-dir TRAINING_DIRECTORY \ 72 | --batch-size 32 \ 73 | --visdom-env mytrainingsession \ 74 | --visdom-server localhost \ 75 | --lr 1e-3 \ 76 | --val-dir TRAINING_DIRECTORY \ 77 | --optim Adam \ 78 | --save saved_model.ckpt 79 |80 | 81 | ## Dataset format 82 | The options `--dataset` and `--train-dir` should point to a directory. 83 | This directory must contain your dataset, meaning: 84 | 1. One file per image to analyze (png, jpg, jpeg, tiff or tif). 85 | 2. One ground truth file called `gt.csv` with the following format: 86 | ``` 87 | filename,count,locations 88 | img1.png,3,"[(28, 52), (58, 53), (135, 50)]" 89 | img2.png,2,"[(92, 47), (33, 82)]" 90 | ``` 91 | Each row of the CSV must describe the ground truth of an image: the count (number) and location of all objects in that image. 92 | The locations are in (y, x) format, being the origin the most top left pixel, y being the pixel row number, and x being the pixel column number. 93 | 94 | Optionally, if you are working on precision agriculture or plant phenotyping you can use an XML file `gt.xml` instead of a CSV. 95 | The required XML specifications can be found in 96 | [https://communityhub.purdue.edu/groups/phenosorg/wiki/APIspecs](https://communityhub.purdue.edu/groups/phenosorg/wiki/APIspecs) 97 | (accessible only to Purdue users) and in [this](https://hammer.figshare.com/articles/Image-based_Plant_Phenotyping_Using_Machine_Learning/7774313) thesis, but this is only useful in agronomy/phenotyping applications. 98 | The XML file is parsed by the file `data_plant_stuff.py`. 99 | 100 | ## Pre-trained models 101 | Models are trained separately for each of the four datasets, as described in the paper: 102 | 1. [Mall dataset](https://lorenz.ecn.purdue.edu/~cvpr2019/pretrained_models/mall,lambdaa=1,BS=32,Adam,LR1e-4.ckpt) 103 | 2. [Pupil dataset](https://lorenz.ecn.purdue.edu/~cvpr2019/pretrained_models/pupil,lambdaa=1,BS=64,SGD,LR1e-3,p=-1,ultrasmallNet.ckpt) 104 | 3. [Plant dataset](https://lorenz.ecn.purdue.edu/~cvpr2019/pretrained_models/plants_20160613_F54,BS=32,Adam,LR1e-5,p=-1.ckpt) 105 | 4. [ShanghaiTechB dataset](https://lorenz.ecn.purdue.edu/~cvpr2019/pretrained_models/shanghai,lambdaa=1,p=-1,BS=32,Adam,LR=1e-4.ckpt) 106 | 107 | The [COPYRIGHT](COPYRIGHT.txt) of the pre-trained models is the same as in this repository. 108 | 109 | As described in the paper, the pre-trained model for the pupil dataset excludes the five central layers. Thus if you want to use this model you will have to use the option `--ultrasmallnet`. 110 | 111 | ## Uninstall 112 |
113 | conda deactivate object-locator 114 | conda env remove --name object-locator 115 |116 | 117 | 118 | ## Code Versioning 119 | The code used in the paper corresponds to the tag `used-for-cvpr2019-submission`. 120 | If you want to reproduce the results, checkout that tag with `git checkout used-for-cvpr2019-submission`. 121 | The master branch is the latest version available, with convenient bug fixes and better documentation. 122 | If you want to develop or retrain your models, we recommend the master branch. 123 | Versions numbers follow [semantic versioning](https://semver.org) and the changelog is in [CHANGELOG.md](CHANGELOG.md). 124 | 125 | 126 | ## Creating an issue 127 | If you're experiencing a problem or a bug, creating a GitHub issue is encouraged, but please include the following: 128 | 1. The commit version of this repository that you ran (`git show | head -n 1`) 129 | 2. The dataset you used (including images and the CSV with groundtruth with the [appropriate format](#datasetformat)) 130 | 3. CPU and GPU model(s) you are using 131 | 4. The full standard output of the training log if you are training, and the testing log if you are evaluating (you can upload it to https://pastebin.com) 132 | 5. The operating system you are using 133 | 6. The command you run to train and evaluate 134 | -------------------------------------------------------------------------------- /object-locator/models/unet_model.py: -------------------------------------------------------------------------------- 1 | __copyright__ = \ 2 | """ 3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 4 | All rights reserved. 5 | 6 | This software is covered by US patents and copyright. 7 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 8 | 9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 10 | 11 | Last Modified: 11/11/2019 12 | """ 13 | __license__ = "CC BY-NC-SA 4.0" 14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp" 15 | __version__ = "1.6.0" 16 | 17 | 18 | import torch 19 | import torch.nn as nn 20 | import torch.nn.functional as F 21 | from torch.autograd import Variable 22 | 23 | from .unet_parts import * 24 | 25 | 26 | class UNet(nn.Module): 27 | def __init__(self, n_channels, n_classes, 28 | height, width, 29 | known_n_points=None, 30 | ultrasmall=False, 31 | device=torch.device('cuda')): 32 | """ 33 | Instantiate a UNet network. 34 | :param n_channels: Number of input channels (e.g, 3 for RGB) 35 | :param n_classes: Number of output classes 36 | :param height: Height of the input images 37 | :param known_n_points: If you know the number of points, 38 | (e.g, one pupil), then set it. 39 | Otherwise it will be estimated by a lateral NN. 40 | If provided, no lateral network will be build 41 | and the resulting UNet will be a FCN. 42 | :param ultrasmall: If True, the 5 central layers are removed, 43 | resulting in a much smaller UNet. 44 | :param device: Which torch device to use. Default: CUDA (GPU). 45 | """ 46 | super(UNet, self).__init__() 47 | 48 | self.ultrasmall = ultrasmall 49 | self.device = device 50 | 51 | # With this network depth, there is a minimum image size 52 | if height < 256 or width < 256: 53 | raise ValueError('Minimum input image size is 256x256, got {}x{}'.\ 54 | format(height, width)) 55 | 56 | self.inc = inconv(n_channels, 64) 57 | self.down1 = down(64, 128) 58 | self.down2 = down(128, 256) 59 | if self.ultrasmall: 60 | self.down3 = down(256, 512, normaliz=False) 61 | self.up1 = up(768, 128) 62 | self.up2 = up(256, 64) 63 | self.up3 = up(128, 64, activ=False) 64 | else: 65 | self.down3 = down(256, 512) 66 | self.down4 = down(512, 512) 67 | self.down5 = down(512, 512) 68 | self.down6 = down(512, 512) 69 | self.down7 = down(512, 512) 70 | self.down8 = down(512, 512, normaliz=False) 71 | self.up1 = up(1024, 512) 72 | self.up2 = up(1024, 512) 73 | self.up3 = up(1024, 512) 74 | self.up4 = up(1024, 512) 75 | self.up5 = up(1024, 256) 76 | self.up6 = up(512, 128) 77 | self.up7 = up(256, 64) 78 | self.up8 = up(128, 64, activ=False) 79 | self.outc = outconv(64, n_classes) 80 | self.out_nonlin = nn.Sigmoid() 81 | 82 | self.known_n_points = known_n_points 83 | if known_n_points is None: 84 | steps = 3 if self.ultrasmall else 8 85 | height_mid_features = height//(2**steps) 86 | width_mid_features = width//(2**steps) 87 | self.branch_1 = nn.Sequential(nn.Linear(height_mid_features*\ 88 | width_mid_features*\ 89 | 512, 90 | 64), 91 | nn.ReLU(inplace=True), 92 | nn.Dropout(p=0.5)) 93 | self.branch_2 = nn.Sequential(nn.Linear(height*width, 64), 94 | nn.ReLU(inplace=True), 95 | nn.Dropout(p=0.5)) 96 | self.regressor = nn.Sequential(nn.Linear(64 + 64, 1), 97 | nn.ReLU()) 98 | 99 | # This layer is not connected anywhere 100 | # It is only here for backward compatibility 101 | self.lin = nn.Linear(1, 1, bias=False) 102 | 103 | def forward(self, x): 104 | 105 | batch_size = x.shape[0] 106 | 107 | x1 = self.inc(x) 108 | x2 = self.down1(x1) 109 | x3 = self.down2(x2) 110 | x4 = self.down3(x3) 111 | if self.ultrasmall: 112 | x = self.up1(x4, x3) 113 | x = self.up2(x, x2) 114 | x = self.up3(x, x1) 115 | else: 116 | x5 = self.down4(x4) 117 | x6 = self.down5(x5) 118 | x7 = self.down6(x6) 119 | x8 = self.down7(x7) 120 | x9 = self.down8(x8) 121 | x = self.up1(x9, x8) 122 | x = self.up2(x, x7) 123 | x = self.up3(x, x6) 124 | x = self.up4(x, x5) 125 | x = self.up5(x, x4) 126 | x = self.up6(x, x3) 127 | x = self.up7(x, x2) 128 | x = self.up8(x, x1) 129 | x = self.outc(x) 130 | x = self.out_nonlin(x) 131 | 132 | # Reshape Bx1xHxW -> BxHxW 133 | # because probability map is real-valued by definition 134 | x = x.squeeze(1) 135 | 136 | if self.known_n_points is None: 137 | middle_layer = x4 if self.ultrasmall else x9 138 | middle_layer_flat = middle_layer.view(batch_size, -1) 139 | x_flat = x.view(batch_size, -1) 140 | 141 | lateral_flat = self.branch_1(middle_layer_flat) 142 | x_flat = self.branch_2(x_flat) 143 | 144 | regression_features = torch.cat((x_flat, lateral_flat), dim=1) 145 | regression = self.regressor(regression_features) 146 | 147 | return x, regression 148 | else: 149 | n_pts = torch.tensor([self.known_n_points]*batch_size, 150 | dtype=torch.get_default_dtype()) 151 | n_pts = n_pts.to(self.device) 152 | return x, n_pts 153 | # summ = torch.sum(x) 154 | # count = self.lin(summ) 155 | 156 | # count = torch.abs(count) 157 | 158 | # if self.known_n_points is not None: 159 | # count = Variable(torch.cuda.FloatTensor([self.known_n_points])) 160 | 161 | # return x, count 162 | 163 | 164 | """ 165 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 166 | All rights reserved. 167 | 168 | This software is covered by US patents and copyright. 169 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 170 | 171 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 172 | 173 | Last Modified: 11/11/2019 174 | """ 175 | -------------------------------------------------------------------------------- /object-locator/find_lr.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | __copyright__ = \ 4 | """ 5 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 6 | All rights reserved. 7 | 8 | This software is covered by US patents and copyright. 9 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 10 | 11 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 12 | 13 | Last Modified: 10/02/2019 14 | """ 15 | __license__ = "CC BY-NC-SA 4.0" 16 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp" 17 | __version__ = "1.6.0" 18 | 19 | 20 | import math 21 | import os 22 | from itertools import chain 23 | from tqdm import tqdm 24 | 25 | import numpy as np 26 | import torch 27 | import torch.optim as optim 28 | from torch import nn 29 | from torch.autograd import Variable 30 | from torchvision import transforms 31 | from torch.utils.data import DataLoader 32 | import torch.optim.lr_scheduler 33 | import matplotlib 34 | matplotlib.use('Agg') 35 | import skimage.transform 36 | from peterpy import peter 37 | from ballpark import ballpark 38 | from matplotlib import pyplot as plt 39 | 40 | from . import losses 41 | from .models import unet_model 42 | from .data import CSVDataset 43 | from .data import csv_collator 44 | from .data import RandomHorizontalFlipImageAndLabel 45 | from .data import RandomVerticalFlipImageAndLabel 46 | from .data import ScaleImageAndLabel 47 | from . import argparser 48 | 49 | 50 | # Parse command line arguments 51 | args = argparser.parse_command_args('training') 52 | 53 | # Tensor type to use, select CUDA or not 54 | torch.set_default_dtype(torch.float32) 55 | device_cpu = torch.device('cpu') 56 | device = torch.device('cuda') if args.cuda else device_cpu 57 | 58 | # Set seeds 59 | np.random.seed(args.seed) 60 | torch.manual_seed(args.seed) 61 | if args.cuda: 62 | torch.cuda.manual_seed_all(args.seed) 63 | 64 | # Data loading code 65 | training_transforms = [] 66 | if not args.no_data_augm: 67 | training_transforms += [RandomHorizontalFlipImageAndLabel(p=0.5)] 68 | training_transforms += [RandomVerticalFlipImageAndLabel(p=0.5)] 69 | training_transforms += [ScaleImageAndLabel(size=(args.height, args.width))] 70 | training_transforms += [transforms.ToTensor()] 71 | training_transforms += [transforms.Normalize((0.5, 0.5, 0.5), 72 | (0.5, 0.5, 0.5))] 73 | trainset = CSVDataset(args.train_dir, 74 | transforms=transforms.Compose(training_transforms), 75 | max_dataset_size=args.max_trainset_size) 76 | trainset_loader = DataLoader(trainset, 77 | batch_size=args.batch_size, 78 | drop_last=args.drop_last_batch, 79 | shuffle=True, 80 | num_workers=args.nThreads, 81 | collate_fn=csv_collator) 82 | 83 | # Model 84 | with peter('Building network'): 85 | model = unet_model.UNet(3, 1, 86 | height=args.height, 87 | width=args.width, 88 | known_n_points=args.n_points) 89 | num_params = sum(p.numel() for p in model.parameters() if p.requires_grad) 90 | print(f" with {ballpark(num_params)} trainable parameters. ", end='') 91 | model = nn.DataParallel(model) 92 | model.to(device) 93 | 94 | 95 | # Loss function 96 | loss_regress = nn.SmoothL1Loss() 97 | loss_loc = losses.WeightedHausdorffDistance(resized_height=args.height, 98 | resized_width=args.width, 99 | p=args.p, 100 | return_2_terms=True, 101 | device=device) 102 | l1_loss = nn.L1Loss(size_average=False) 103 | mse_loss = nn.MSELoss(reduce=False) 104 | 105 | optimizer = optim.SGD(model.parameters(), 106 | lr=999) # will be set later 107 | 108 | 109 | def find_lr(init_value = 1e-6, final_value=1e-3, beta = 0.7): 110 | num = len(trainset_loader)-1 111 | mult = (final_value / init_value) ** (1/num) 112 | lr = init_value 113 | optimizer.param_groups[0]['lr'] = lr 114 | avg_loss = 0. 115 | best_loss = 0. 116 | batch_num = 0 117 | losses = [] 118 | log_lrs = [] 119 | for imgs, dicts in tqdm(trainset_loader): 120 | batch_num += 1 121 | 122 | # Pull info from this batch and move to device 123 | imgs = imgs.to(device) 124 | imgs = Variable(imgs) 125 | target_locations = [dictt['locations'].to(device) 126 | for dictt in dicts] 127 | target_counts = [dictt['count'].to(device) 128 | for dictt in dicts] 129 | target_orig_heights = [dictt['orig_height'].to(device) 130 | for dictt in dicts] 131 | target_orig_widths = [dictt['orig_width'].to(device) 132 | for dictt in dicts] 133 | 134 | # Lists -> Tensor batches 135 | target_counts = torch.stack(target_counts) 136 | target_orig_heights = torch.stack(target_orig_heights) 137 | target_orig_widths = torch.stack(target_orig_widths) 138 | target_orig_sizes = torch.stack((target_orig_heights, 139 | target_orig_widths)).transpose(0, 1) 140 | # As before, get the loss for this mini-batch of inputs/outputs 141 | optimizer.zero_grad() 142 | est_maps, est_counts = model.forward(imgs) 143 | term1, term2 = loss_loc.forward(est_maps, 144 | target_locations, 145 | target_orig_sizes) 146 | target_counts = target_counts.view(-1) 147 | est_counts = est_counts.view(-1) 148 | target_counts = target_counts.view(-1) 149 | term3 = loss_regress.forward(est_counts, target_counts) 150 | term3 *= args.lambdaa 151 | loss = term1 + term2 + term3 152 | 153 | # Compute the smoothed loss 154 | avg_loss = beta * avg_loss + (1-beta) *loss.item() 155 | smoothed_loss = avg_loss / (1 - beta**batch_num) 156 | 157 | # Stop if the loss is exploding 158 | if (batch_num > 1 and smoothed_loss > 4 * best_loss): 159 | return log_lrs, losses 160 | 161 | # Record the best loss 162 | if smoothed_loss < best_loss or batch_num==1: 163 | best_loss = smoothed_loss 164 | 165 | # Store the values 166 | losses.append(smoothed_loss) 167 | log_lrs.append(math.log10(lr)) 168 | 169 | # Do the SGD step 170 | loss.backward() 171 | optimizer.step() 172 | 173 | # Update the lr for the next step 174 | lr *= mult 175 | optimizer.param_groups[0]['lr'] = lr 176 | return log_lrs, losses 177 | 178 | logs, losses = find_lr() 179 | plt.plot(logs, losses) 180 | plt.savefig('/data/jprat/plot_beta0.7.png') 181 | 182 | 183 | """ 184 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 185 | All rights reserved. 186 | 187 | This software is covered by US patents and copyright. 188 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 189 | 190 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 191 | 192 | Last Modified: 10/02/2019 193 | """ 194 | -------------------------------------------------------------------------------- /object-locator/logger.py: -------------------------------------------------------------------------------- 1 | __copyright__ = \ 2 | """ 3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 4 | All rights reserved. 5 | 6 | This software is covered by US patents and copyright. 7 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 8 | 9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 10 | 11 | Last Modified: 10/02/2019 12 | """ 13 | __license__ = "CC BY-NC-SA 4.0" 14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp" 15 | __version__ = "1.6.0" 16 | 17 | 18 | import visdom 19 | import torch 20 | import numbers 21 | from . import utils 22 | 23 | from torch.autograd import Variable 24 | 25 | class Logger(): 26 | def __init__(self, 27 | server=None, 28 | port=8989, 29 | env_name='main'): 30 | """ 31 | Logger that connects to a Visdom server 32 | and sends training losses/metrics and images of any kind. 33 | 34 | :param server: Host name of the server (e.g, http://localhost), 35 | without the port number. If None, 36 | this Logger will do nothing at all 37 | (it will not connect to any server, 38 | and the functions here will do nothing). 39 | :param port: Port number of the Visdom server. 40 | :param env_name: Name of the environment within the Visdom 41 | server where everything you sent to it will go. 42 | :param terms_legends: Legend of each term. 43 | """ 44 | 45 | if server is None: 46 | self.train_losses = utils.nothing 47 | self.val_losses = utils.nothing 48 | self.image = utils.nothing 49 | print('W: Not connected to any Visdom server. ' 50 | 'You will not visualize any training/validation plot ' 51 | 'or intermediate image') 52 | else: 53 | # Connect to Visdom 54 | self.client = visdom.Visdom(server=server, 55 | env=env_name, 56 | port=port) 57 | if self.client.check_connection(): 58 | print(f'Connected to Visdom server ' 59 | f'{server}:{port}') 60 | else: 61 | print(f'E: cannot connect to Visdom server ' 62 | f'{server}:{port}') 63 | exit(-1) 64 | 65 | # Each of the 'windows' in visdom web panel 66 | self.viz_train_input_win = None 67 | self.viz_train_loss_win = None 68 | self.viz_train_gt_win = None 69 | self.viz_train_est_win = None 70 | self.viz_val_input_win = None 71 | self.viz_val_loss_win = None 72 | self.viz_val_gt_win = None 73 | self.viz_val_est_win = None 74 | 75 | # Visdom only supports CPU Tensors 76 | self.device = torch.device("cpu") 77 | 78 | 79 | def train_losses(self, terms, iteration_number, terms_legends=None): 80 | """ 81 | Plot a new point of the training losses (scalars) to Visdom. 82 | All losses will be plotted in the same figure/window. 83 | 84 | :param terms: List of scalar losses. 85 | Each element will be a different plot in the y axis. 86 | :param iteration_number: Value of the x axis in the plot. 87 | :param terms_legends: Legend of each term. 88 | """ 89 | 90 | # Watch dog 91 | if terms_legends is not None and \ 92 | len(terms) != len(terms_legends): 93 | raise ValueError('The number of "terms" and "terms_legends" must be equal, got %s and %s, respectively' 94 | % (len(terms), len(terms_legends))) 95 | if not isinstance(iteration_number, numbers.Number): 96 | raise ValueError('iteration_number must be a number, got %s' 97 | % iteration_number) 98 | 99 | # Make terms CPU Tensors 100 | curated_terms = [] 101 | for term in terms: 102 | if isinstance(term, numbers.Number): 103 | curated_term = torch.tensor([term]) 104 | elif isinstance(term, torch.Tensor): 105 | curated_term = term 106 | else: 107 | raise ValueError('there is a term with an unsupported type' 108 | f'({type(term)}') 109 | curated_term = curated_term.to(self.device) 110 | curated_term = curated_term.view(1) 111 | curated_terms.append(curated_term) 112 | 113 | y = torch.cat(curated_terms).view(1, -1).data 114 | x = torch.Tensor([iteration_number]).repeat(1, len(terms)) 115 | if terms_legends is None: 116 | terms_legends = ['Term %s' % t 117 | for t in range(1, len(terms) + 1)] 118 | 119 | # Send training loss to Visdom 120 | self.win_train_loss = \ 121 | self.client.line(Y=y, 122 | X=x, 123 | opts=dict(title='Training', 124 | legend=terms_legends, 125 | ylabel='Loss', 126 | xlabel='Epoch'), 127 | update='append', 128 | win='train_losses') 129 | if self.win_train_loss == 'win does not exist': 130 | self.win_train_loss = \ 131 | self.client.line(Y=y, 132 | X=x, 133 | opts=dict(title='Training', 134 | legend=terms_legends, 135 | ylabel='Loss', 136 | xlabel='Epoch'), 137 | win='train_losses') 138 | 139 | def image(self, imgs, titles, window_ids): 140 | """Send images to Visdom. 141 | Each image will be shown in a different window/plot. 142 | 143 | :param imgs: List of numpy images. 144 | :param titles: List of titles of each image. 145 | :param window_ids: List of window IDs. 146 | """ 147 | 148 | # Watchdog 149 | if not(len(imgs) == len(titles) == len(window_ids)): 150 | raise ValueError('The number of "imgs", "titles" and ' 151 | '"window_ids" must be equal, got ' 152 | '%s, %s and %s, respectively' 153 | % (len(imgs), len(titles), len(window_ids))) 154 | 155 | for img, title, win in zip(imgs, titles, window_ids): 156 | self.client.image(img, 157 | opts=dict(title=title), 158 | win=str(win)) 159 | 160 | def val_losses(self, terms, iteration_number, terms_legends=None): 161 | """ 162 | Plot a new point of the training losses (scalars) to Visdom. All losses will be plotted in the same figure/window. 163 | 164 | :param terms: List of scalar losses. 165 | Each element will be a different plot in the y axis. 166 | :param iteration_number: Value of the x axis in the plot. 167 | :param terms_legends: Legend of each term. 168 | """ 169 | 170 | # Watchdog 171 | if terms_legends is not None and \ 172 | len(terms) != len(terms_legends): 173 | raise ValueError('The number of "terms" and "terms_legends" must be equal, got %s and %s, respectively' 174 | % (len(terms), len(terms_legends))) 175 | if not isinstance(iteration_number, numbers.Number): 176 | raise ValueError('iteration_number must be a number, got %s' 177 | % iteration_number) 178 | 179 | # Make terms CPU Tensors 180 | curated_terms = [] 181 | for term in terms: 182 | if isinstance(term, numbers.Number): 183 | curated_term = torch.tensor([term], 184 | dtype=torch.get_default_dtype()) 185 | elif isinstance(term, torch.Tensor): 186 | curated_term = term 187 | else: 188 | raise ValueError('there is a term with an unsupported type' 189 | f'({type(term)}') 190 | curated_term = curated_term.to(self.device) 191 | curated_term = curated_term.view(1) 192 | curated_terms.append(curated_term) 193 | 194 | y = torch.stack(curated_terms).view(1, -1) 195 | x = torch.Tensor([iteration_number]).repeat(1, len(terms)) 196 | if terms_legends is None: 197 | terms_legends = ['Term %s' % t for t in range(1, len(terms) + 1)] 198 | 199 | # Send validation loss to Visdom 200 | self.win_val_loss = \ 201 | self.client.line(Y=y, 202 | X=x, 203 | opts=dict(title='Validation', 204 | legend=terms_legends, 205 | ylabel='Loss', 206 | xlabel='Epoch'), 207 | update='append', 208 | win='val_metrics') 209 | if self.win_val_loss == 'win does not exist': 210 | self.win_val_loss = \ 211 | self.client.line(Y=y, 212 | X=x, 213 | opts=dict(title='Validation', 214 | legend=terms_legends, 215 | ylabel='Loss', 216 | xlabel='Epoch'), 217 | win='val_metrics') 218 | 219 | 220 | """ 221 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 222 | All rights reserved. 223 | 224 | This software is covered by US patents and copyright. 225 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 226 | 227 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 228 | 229 | Last Modified: 10/02/2019 230 | """ 231 | -------------------------------------------------------------------------------- /object-locator/losses.py: -------------------------------------------------------------------------------- 1 | __copyright__ = \ 2 | """ 3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 4 | All rights reserved. 5 | 6 | This software is covered by US patents and copyright. 7 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 8 | 9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 10 | 11 | Last Modified: 10/02/2019 12 | """ 13 | __license__ = "CC BY-NC-SA 4.0" 14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp" 15 | __version__ = "1.6.0" 16 | 17 | 18 | import math 19 | import torch 20 | from sklearn.utils.extmath import cartesian 21 | import numpy as np 22 | from torch.nn import functional as F 23 | import os 24 | import time 25 | from sklearn.metrics.pairwise import pairwise_distances 26 | from sklearn.neighbors.kde import KernelDensity 27 | import skimage.io 28 | from matplotlib import pyplot as plt 29 | from torch import nn 30 | 31 | 32 | torch.set_default_dtype(torch.float32) 33 | 34 | 35 | def _assert_no_grad(variables): 36 | for var in variables: 37 | assert not var.requires_grad, \ 38 | "nn criterions don't compute the gradient w.r.t. targets - please " \ 39 | "mark these variables as volatile or not requiring gradients" 40 | 41 | 42 | def cdist(x, y): 43 | """ 44 | Compute distance between each pair of the two collections of inputs. 45 | :param x: Nxd Tensor 46 | :param y: Mxd Tensor 47 | :res: NxM matrix where dist[i,j] is the norm between x[i,:] and y[j,:], 48 | i.e. dist[i,j] = ||x[i,:]-y[j,:]|| 49 | 50 | """ 51 | differences = x.unsqueeze(1) - y.unsqueeze(0) 52 | distances = torch.sum(differences**2, -1).sqrt() 53 | return distances 54 | 55 | 56 | def averaged_hausdorff_distance(set1, set2, max_ahd=np.inf): 57 | """ 58 | Compute the Averaged Hausdorff Distance function 59 | between two unordered sets of points (the function is symmetric). 60 | Batches are not supported, so squeeze your inputs first! 61 | :param set1: Array/list where each row/element is an N-dimensional point. 62 | :param set2: Array/list where each row/element is an N-dimensional point. 63 | :param max_ahd: Maximum AHD possible to return if any set is empty. Default: inf. 64 | :return: The Averaged Hausdorff Distance between set1 and set2. 65 | """ 66 | 67 | if len(set1) == 0 or len(set2) == 0: 68 | return max_ahd 69 | 70 | set1 = np.array(set1) 71 | set2 = np.array(set2) 72 | 73 | assert set1.ndim == 2, 'got %s' % set1.ndim 74 | assert set2.ndim == 2, 'got %s' % set2.ndim 75 | 76 | assert set1.shape[1] == set2.shape[1], \ 77 | 'The points in both sets must have the same number of dimensions, got %s and %s.'\ 78 | % (set2.shape[1], set2.shape[1]) 79 | 80 | d2_matrix = pairwise_distances(set1, set2, metric='euclidean') 81 | 82 | res = np.average(np.min(d2_matrix, axis=0)) + \ 83 | np.average(np.min(d2_matrix, axis=1)) 84 | 85 | return res 86 | 87 | 88 | class AveragedHausdorffLoss(nn.Module): 89 | def __init__(self): 90 | super(nn.Module, self).__init__() 91 | 92 | def forward(self, set1, set2): 93 | """ 94 | Compute the Averaged Hausdorff Distance function 95 | between two unordered sets of points (the function is symmetric). 96 | Batches are not supported, so squeeze your inputs first! 97 | :param set1: Tensor where each row is an N-dimensional point. 98 | :param set2: Tensor where each row is an N-dimensional point. 99 | :return: The Averaged Hausdorff Distance between set1 and set2. 100 | """ 101 | 102 | assert set1.ndimension() == 2, 'got %s' % set1.ndimension() 103 | assert set2.ndimension() == 2, 'got %s' % set2.ndimension() 104 | 105 | assert set1.size()[1] == set2.size()[1], \ 106 | 'The points in both sets must have the same number of dimensions, got %s and %s.'\ 107 | % (set2.size()[1], set2.size()[1]) 108 | 109 | d2_matrix = cdist(set1, set2) 110 | 111 | # Modified Chamfer Loss 112 | term_1 = torch.mean(torch.min(d2_matrix, 1)[0]) 113 | term_2 = torch.mean(torch.min(d2_matrix, 0)[0]) 114 | 115 | res = term_1 + term_2 116 | 117 | return res 118 | 119 | 120 | class WeightedHausdorffDistance(nn.Module): 121 | def __init__(self, 122 | resized_height, resized_width, 123 | p=-9, 124 | return_2_terms=False, 125 | device=torch.device('cpu')): 126 | """ 127 | :param resized_height: Number of rows in the image. 128 | :param resized_width: Number of columns in the image. 129 | :param p: Exponent in the generalized mean. -inf makes it the minimum. 130 | :param return_2_terms: Whether to return the 2 terms 131 | of the WHD instead of their sum. 132 | Default: False. 133 | :param device: Device where all Tensors will reside. 134 | """ 135 | super(nn.Module, self).__init__() 136 | 137 | # Prepare all possible (row, col) locations in the image 138 | self.height, self.width = resized_height, resized_width 139 | self.resized_size = torch.tensor([resized_height, 140 | resized_width], 141 | dtype=torch.get_default_dtype(), 142 | device=device) 143 | self.max_dist = math.sqrt(resized_height**2 + resized_width**2) 144 | self.n_pixels = resized_height * resized_width 145 | self.all_img_locations = torch.from_numpy(cartesian([np.arange(resized_height), 146 | np.arange(resized_width)])) 147 | # Convert to appropiate type 148 | self.all_img_locations = self.all_img_locations.to(device=device, 149 | dtype=torch.get_default_dtype()) 150 | 151 | self.return_2_terms = return_2_terms 152 | self.p = p 153 | 154 | def forward(self, prob_map, gt, orig_sizes): 155 | """ 156 | Compute the Weighted Hausdorff Distance function 157 | between the estimated probability map and ground truth points. 158 | The output is the WHD averaged through all the batch. 159 | 160 | :param prob_map: (B x H x W) Tensor of the probability map of the estimation. 161 | B is batch size, H is height and W is width. 162 | Values must be between 0 and 1. 163 | :param gt: List of Tensors of the Ground Truth points. 164 | Must be of size B as in prob_map. 165 | Each element in the list must be a 2D Tensor, 166 | where each row is the (y, x), i.e, (row, col) of a GT point. 167 | :param orig_sizes: Bx2 Tensor containing the size 168 | of the original images. 169 | B is batch size. 170 | The size must be in (height, width) format. 171 | :param orig_widths: List of the original widths for each image 172 | in the batch. 173 | :return: Single-scalar Tensor with the Weighted Hausdorff Distance. 174 | If self.return_2_terms=True, then return a tuple containing 175 | the two terms of the Weighted Hausdorff Distance. 176 | """ 177 | 178 | _assert_no_grad(gt) 179 | 180 | assert prob_map.dim() == 3, 'The probability map must be (B x H x W)' 181 | assert prob_map.size()[1:3] == (self.height, self.width), \ 182 | 'You must configure the WeightedHausdorffDistance with the height and width of the ' \ 183 | 'probability map that you are using, got a probability map of size %s'\ 184 | % str(prob_map.size()) 185 | 186 | batch_size = prob_map.shape[0] 187 | assert batch_size == len(gt) 188 | 189 | terms_1 = [] 190 | terms_2 = [] 191 | for b in range(batch_size): 192 | 193 | # One by one 194 | prob_map_b = prob_map[b, :, :] 195 | gt_b = gt[b] 196 | orig_size_b = orig_sizes[b, :] 197 | norm_factor = (orig_size_b/self.resized_size).unsqueeze(0) 198 | n_gt_pts = gt_b.size()[0] 199 | 200 | # Corner case: no GT points 201 | if gt_b.ndimension() == 1 and (gt_b < 0).all().item() == 0: 202 | terms_1.append(torch.tensor([0], 203 | dtype=torch.get_default_dtype())) 204 | terms_2.append(torch.tensor([self.max_dist], 205 | dtype=torch.get_default_dtype())) 206 | continue 207 | 208 | # Pairwise distances between all possible locations and the GTed locations 209 | n_gt_pts = gt_b.size()[0] 210 | normalized_x = norm_factor.repeat(self.n_pixels, 1) *\ 211 | self.all_img_locations 212 | normalized_y = norm_factor.repeat(len(gt_b), 1)*gt_b 213 | d_matrix = cdist(normalized_x, normalized_y) 214 | 215 | # Reshape probability map as a long column vector, 216 | # and prepare it for multiplication 217 | p = prob_map_b.view(prob_map_b.nelement()) 218 | n_est_pts = p.sum() 219 | p_replicated = p.view(-1, 1).repeat(1, n_gt_pts) 220 | 221 | # Weighted Hausdorff Distance 222 | term_1 = (1 / (n_est_pts + 1e-6)) * \ 223 | torch.sum(p * torch.min(d_matrix, 1)[0]) 224 | weighted_d_matrix = (1 - p_replicated)*self.max_dist + p_replicated*d_matrix 225 | minn = generaliz_mean(weighted_d_matrix, 226 | p=self.p, 227 | dim=0, keepdim=False) 228 | term_2 = torch.mean(minn) 229 | 230 | # terms_1[b] = term_1 231 | # terms_2[b] = term_2 232 | terms_1.append(term_1) 233 | terms_2.append(term_2) 234 | 235 | terms_1 = torch.stack(terms_1) 236 | terms_2 = torch.stack(terms_2) 237 | 238 | if self.return_2_terms: 239 | res = terms_1.mean(), terms_2.mean() 240 | else: 241 | res = terms_1.mean() + terms_2.mean() 242 | 243 | return res 244 | 245 | 246 | def generaliz_mean(tensor, dim, p=-9, keepdim=False): 247 | # """ 248 | # Computes the softmin along some axes. 249 | # Softmin is the same as -softmax(-x), i.e, 250 | # softmin(x) = -log(sum_i(exp(-x_i))) 251 | 252 | # The smoothness of the operator is controlled with k: 253 | # softmin(x) = -log(sum_i(exp(-k*x_i)))/k 254 | 255 | # :param input: Tensor of any dimension. 256 | # :param dim: (int or tuple of ints) The dimension or dimensions to reduce. 257 | # :param keepdim: (bool) Whether the output tensor has dim retained or not. 258 | # :param k: (float>0) How similar softmin is to min (the lower the more smooth). 259 | # """ 260 | # return -torch.log(torch.sum(torch.exp(-k*input), dim, keepdim))/k 261 | """ 262 | The generalized mean. It corresponds to the minimum when p = -inf. 263 | https://en.wikipedia.org/wiki/Generalized_mean 264 | :param tensor: Tensor of any dimension. 265 | :param dim: (int or tuple of ints) The dimension or dimensions to reduce. 266 | :param keepdim: (bool) Whether the output tensor has dim retained or not. 267 | :param p: (float<0). 268 | """ 269 | assert p < 0 270 | res= torch.mean((tensor + 1e-6)**p, dim, keepdim=keepdim)**(1./p) 271 | return res 272 | 273 | 274 | """ 275 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 276 | All rights reserved. 277 | 278 | This software is covered by US patents and copyright. 279 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 280 | 281 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 282 | 283 | Last Modified: 10/02/2019 284 | """ 285 | -------------------------------------------------------------------------------- /object-locator/data_plant_stuff.py: -------------------------------------------------------------------------------- 1 | __copyright__ = \ 2 | """ 3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 4 | All rights reserved. 5 | 6 | This software is covered by US patents and copyright. 7 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 8 | 9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 10 | 11 | Last Modified: 10/02/2019 12 | """ 13 | __license__ = "CC BY-NC-SA 4.0" 14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp" 15 | __version__ = "1.6.0" 16 | 17 | 18 | import os 19 | import random 20 | from collections import OrderedDict 21 | 22 | from PIL import Image 23 | import numpy as np 24 | import torch 25 | from torchvision import datasets 26 | from torchvision import transforms 27 | import xmltodict 28 | from parse import parse 29 | 30 | from . import get_image_size 31 | 32 | IMG_EXTENSIONS = ['.png', '.jpeg', '.jpg', '.tiff'] 33 | 34 | torch.set_default_dtype(torch.float32) 35 | 36 | 37 | class XMLDataset(torch.utils.data.Dataset): 38 | def __init__(self, 39 | directory, 40 | transforms=None, 41 | max_dataset_size=float('inf'), 42 | ignore_gt=False, 43 | seed=0): 44 | """XMLDataset. 45 | The sample images of this dataset must be all inside one directory. 46 | Inside the same directory, there must be one XML file as described by 47 | https://communityhub.purdue.edu/groups/phenosorg/wiki/APIspecs 48 | (minimum XML API version is v.0.4.0). 49 | If there is no XML file, metrics will not be computed, 50 | and only estimations will be provided. 51 | :param directory: Directory with all the images and the XML file. 52 | :param transform: Transform to be applied to each image. 53 | :param max_dataset_size: Only use the first N images in the directory. 54 | :param ignore_gt: Ignore the GT in the XML file, 55 | i.e, provide samples without plant locations or counts. 56 | :param seed: Random seed. 57 | """ 58 | 59 | self.root_dir = directory 60 | self.transforms = transforms 61 | 62 | # Get list of files in the dataset directory, 63 | # and the filename of the XML 64 | listfiles = os.listdir(directory) 65 | xml_filenames = [f for f in listfiles if f.endswith('.xml')] 66 | if len(xml_filenames) == 1: 67 | xml_filename = xml_filenames[0] 68 | elif len(xml_filenames) == 0: 69 | xml_filename = None 70 | else: 71 | print(f"E: there is more than one XML file in '{directory}'") 72 | exit(-1) 73 | 74 | # Ignore files that are not images 75 | listfiles = [f for f in listfiles 76 | if any(f.lower().endswith(ext) for ext in IMG_EXTENSIONS)] 77 | 78 | # Shuffle list of files 79 | np.random.seed(seed) 80 | random.shuffle(listfiles) 81 | 82 | if len(listfiles) == 0: 83 | raise ValueError(f"There are no images in '{directory}'") 84 | 85 | if xml_filename is None: 86 | print('W: The dataset directory %s does not contain ' 87 | 'a XML file with groundtruth. Metrics will not be evaluated.' 88 | 'Only estimations will be returned.' % directory) 89 | 90 | self.there_is_gt = (xml_filename is not None) and (not ignore_gt) 91 | 92 | # Read all XML as a string 93 | with open(os.path.join(directory, xml_filename), 'r') as fd: 94 | xml_str = fd.read() 95 | 96 | # Convert to dictionary 97 | # (some elements we expect to have multiple repetitions, 98 | # so put them in a list) 99 | xml_dict = xmltodict.parse(xml_str, 100 | force_list=['field', 101 | 'panel', 102 | 'plot', 103 | 'plant']) 104 | 105 | # Check API version number 106 | try: 107 | api_version = xml_dict['fields']['@apiversion'] 108 | except: 109 | # An unknown version number means it's the very first one 110 | # when we did not have api version numbers 111 | api_version = '0.1.0' 112 | major_version, minor_version, _ = parse('{}.{}.{}', api_version) 113 | major_version = int(major_version) 114 | minor_version = int(minor_version) 115 | if not(major_version == 0 and minor_version == 4): 116 | raise ValueError('An XML with API v0.4 is required.') 117 | 118 | # Create the dictionary with the entire dataset 119 | dictt = {} 120 | for field in xml_dict['fields']['field']: 121 | for panel in field['panels']['panel']: 122 | for plot in panel['plots']['plot']: 123 | 124 | if self.there_is_gt and \ 125 | not('plant_count' in plot and \ 126 | 'plants' in plot): 127 | # There is GT for some plots but not this one 128 | continue 129 | 130 | filename = plot['orthophoto_chop_filename'] 131 | if 'plot_number' in plot: 132 | plot_number = plot['plot_number'] 133 | else: 134 | plot_number = 'unknown' 135 | if 'subrow_grid_location' in plot: 136 | subrow_grid_x = \ 137 | int(plot['subrow_grid_location']['x']['#text']) 138 | subrow_grid_y = \ 139 | int(plot['subrow_grid_location']['y']['#text']) 140 | else: 141 | subrow_grid_x = 'unknown' 142 | subrow_grid_y = 'unknown' 143 | if 'row_number' in plot: 144 | row_number = plot['row_number'] 145 | else: 146 | row_number = 'unknown' 147 | if 'range_number' in plot: 148 | range_number = plot['range_number'] 149 | else: 150 | range_number = 'unknown' 151 | img_abspath = os.path.join(self.root_dir, filename) 152 | orig_width, orig_height = \ 153 | get_image_size.get_image_size(img_abspath) 154 | with torch.no_grad(): 155 | orig_height = torch.tensor( 156 | orig_height, dtype=torch.get_default_dtype()) 157 | orig_width = torch.tensor( 158 | orig_width, dtype=torch.get_default_dtype()) 159 | dictt[filename] = {'filename': filename, 160 | 'plot_number': plot_number, 161 | 'subrow_grid_location_x': subrow_grid_x, 162 | 'subrow_grid_location_y': subrow_grid_y, 163 | 'row_number': row_number, 164 | 'range_number': range_number, 165 | 'orig_width': orig_width, 166 | 'orig_height': orig_height} 167 | if self.there_is_gt: 168 | count = int(plot['plant_count']) 169 | locations = [] 170 | for plant in plot['plants']['plant']: 171 | for y in plant['location']['y']: 172 | if y['@units'] == 'pixels' and \ 173 | y['@wrt'] == 'plot': 174 | y = float(y['#text']) 175 | break 176 | for x in plant['location']['x']: 177 | if x['@units'] == 'pixels' and \ 178 | x['@wrt'] == 'plot': 179 | x = float(x['#text']) 180 | break 181 | locations.append([y, x]) 182 | dictt[filename]['count'] = count 183 | dictt[filename]['locations'] = locations 184 | 185 | # Use an Ordered Dictionary to allow random access 186 | dictt = OrderedDict(dictt.items()) 187 | self.dict_list = list(dictt.items()) 188 | 189 | # Make dataset smaller 190 | new_dataset_length = min(len(dictt), max_dataset_size) 191 | dictt = {key: elem_dict 192 | for key, elem_dict in 193 | self.dict_list[:new_dataset_length]} 194 | self.dict_list = list(dictt.items()) 195 | 196 | def __len__(self): 197 | return len(self.dict_list) 198 | 199 | def __getitem__(self, idx): 200 | """Get one element of the dataset. 201 | Returns a tuple. The first element is the image. 202 | The second element is a dictionary containing the labels of that image. 203 | The dictionary may not contain the location and count if the original 204 | XML did not include it. 205 | 206 | :param idx: Index of the image in the dataset to get. 207 | """ 208 | 209 | filename, dictionary = self.dict_list[idx] 210 | img_abspath = os.path.join(self.root_dir, filename) 211 | 212 | if self.there_is_gt: 213 | # list --> Tensors 214 | with torch.no_grad(): 215 | dictionary['locations'] = torch.tensor( 216 | dictionary['locations'], 217 | dtype=torch.get_default_dtype()) 218 | dictionary['count'] = torch.tensor( 219 | dictionary['count'], 220 | dtype=torch.get_default_dtype()) 221 | # else: 222 | # filename = self.listfiles[idx] 223 | # img_abspath = os.path.join(self.root_dir, filename) 224 | # orig_width, orig_height = \ 225 | # get_image_size.get_image_size(img_abspath) 226 | # with torch.no_grad(): 227 | # orig_height = torch.tensor( 228 | # orig_height, dtype=torch.get_default_dtype()) 229 | # orig_width = torch.tensor( 230 | # orig_width, dtype=torch.get_default_dtype()) 231 | # dictionary = {'filename': self.listfiles[idx], 232 | # 'orig_width': orig_width, 233 | # 'orig_height': orig_height} 234 | 235 | img = Image.open(img_abspath) 236 | 237 | img_transformed = img 238 | transformed_dictionary = dictionary 239 | 240 | # Apply all transformations provided 241 | if self.transforms is not None: 242 | for transform in self.transforms.transforms: 243 | if hasattr(transform, 'modifies_label'): 244 | img_transformed, transformed_dictionary = \ 245 | transform(img_transformed, transformed_dictionary) 246 | else: 247 | img_transformed = transform(img_transformed) 248 | 249 | # Prevents crash when making a batch out of an empty tensor 250 | if self.there_is_gt and dictionary['count'].item() == 0: 251 | with torch.no_grad(): 252 | dictionary['locations'] = torch.tensor([-1, -1], 253 | dtype=torch.get_default_dtype()) 254 | 255 | return (img_transformed, transformed_dictionary) 256 | 257 | 258 | """ 259 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 260 | All rights reserved. 261 | 262 | This software is covered by US patents and copyright. 263 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 264 | 265 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 266 | 267 | Last Modified: 10/02/2019 268 | """ 269 | -------------------------------------------------------------------------------- /object-locator/utils.py: -------------------------------------------------------------------------------- 1 | __copyright__ = \ 2 | """ 3 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 4 | All rights reserved. 5 | 6 | This software is covered by US patents and copyright. 7 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 8 | 9 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 10 | 11 | Last Modified: 10/02/2019 12 | """ 13 | __license__ = "CC BY-NC-SA 4.0" 14 | __authors__ = "Javier Ribera, David Guera, Yuhao Chen, Edward J. Delp" 15 | __version__ = "1.6.0" 16 | 17 | 18 | import torch 19 | import numpy as np 20 | import sklearn.mixture 21 | import scipy.stats 22 | import cv2 23 | from . import bmm 24 | from matplotlib import pyplot as plt 25 | import matplotlib.cm 26 | import scipy.stats 27 | 28 | class Normalizer(): 29 | def __init__(self, new_size_height, new_size_width): 30 | """ 31 | Normalizer. 32 | Converts coordinates in an original image size 33 | to a new image size (resized/normalized). 34 | 35 | :param new_size_height: (int) Height of the new (resized) image size. 36 | :param new_size_width: (int) Width of the new (resized) image size. 37 | """ 38 | new_size_height = int(new_size_height) 39 | new_size_width = int(new_size_width) 40 | 41 | self.new_size = np.array([new_size_height, new_size_width]) 42 | 43 | def unnormalize(self, coordinates_yx_normalized, orig_img_size): 44 | """ 45 | Unnormalize coordinates, 46 | i.e, make them with respect to the original image. 47 | 48 | :param coordinates_yx_normalized: 49 | :param orig_size: Original image size ([height, width]). 50 | :return: Unnormalized coordinates 51 | """ 52 | 53 | orig_img_size = np.array(orig_img_size) 54 | assert orig_img_size.ndim == 1 55 | assert len(orig_img_size) == 2 56 | 57 | norm_factor = orig_img_size / self.new_size 58 | norm_factor = np.tile(norm_factor, (len(coordinates_yx_normalized),1)) 59 | coordinates_yx_unnormalized = norm_factor*coordinates_yx_normalized 60 | 61 | return coordinates_yx_unnormalized 62 | 63 | def threshold(array, tau): 64 | """ 65 | Threshold an array using either hard thresholding, Otsu thresholding or beta-fitting. 66 | 67 | If the threshold value is fixed, this function returns 68 | the mask and the threshold used to obtain the mask. 69 | When using tau=-1, the threshold is obtained as described in the Otsu method. 70 | When using tau=-2, it also returns the fitted 2-beta Mixture Model. 71 | 72 | 73 | :param array: Array to threshold. 74 | :param tau: (float) Threshold to use. 75 | Values above tau become 1, and values below tau become 0. 76 | If -1, use Otsu thresholding. 77 | If -2, fit a mixture of 2 beta distributions, and use 78 | the average of the two means. 79 | :return: The tuple (mask, threshold). 80 | If tau==-2, returns the tuple (mask, otsu_tau, ((rv1, rv2), (pi1, pi2))). 81 | 82 | """ 83 | if tau == -1: 84 | # Otsu thresholding 85 | minn, maxx = array.min(), array.max() 86 | array_scaled = ((array - minn)/(maxx - minn)*255) \ 87 | .round().astype(np.uint8).squeeze() 88 | tau, mask = cv2.threshold(array_scaled, 89 | 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) 90 | tau = minn + (tau/255)*(maxx - minn) 91 | # print(f'Otsu selected tau={tau_otsu}') 92 | elif tau == -2: 93 | array_flat = array.flatten() 94 | ((a1, b1), (a2, b2)), (pi1, pi2), niter = bmm.estimate(array_flat, list(range(2))) 95 | rv1 = scipy.stats.beta(a1, b1) 96 | rv2 = scipy.stats.beta(a2, b2) 97 | 98 | tau = rv2.mean() 99 | mask = cv2.inRange(array, tau, 1) 100 | 101 | return mask, tau, ((rv1, pi1), (rv2, pi2)) 102 | else: 103 | # Thresholding with a fixed threshold tau 104 | mask = cv2.inRange(array, tau, 1) 105 | 106 | return mask, tau 107 | 108 | 109 | class AccBetaMixtureModel(): 110 | 111 | def __init__(self, n_components=2, n_pts=1000): 112 | """ 113 | Accumulator that tracks multiple Mixture Models based on Beta distributions. 114 | Each mixture is a tuple (scipy.RV, weight). 115 | 116 | :param n_components: (int) Number of components in the mixtures. 117 | :param n_pts: Number of points in the x axis (values the RV can take in [0, 1]) 118 | """ 119 | self.n_components = n_components 120 | self.mixtures = [] 121 | self.x = np.linspace(0, 1, n_pts) 122 | 123 | def feed(self, mixture): 124 | """ 125 | Accumulate another mixture so that this AccBetaMixtureModel can track it. 126 | 127 | :param mixture: List/Tuple of mixtures, i.e, ((RV, weight), (RV, weight), ...) 128 | """ 129 | assert len(mixture) == self.n_components 130 | 131 | self.mixtures.append(mixture) 132 | 133 | def plot(self): 134 | """ 135 | Create and return plots showing a variety of stats 136 | of the mixtures feeded into this object. 137 | """ 138 | assert len(self.mixtures) > 0 139 | 140 | figs = {} 141 | 142 | # Compute the mean of the pdf of each component 143 | pdf_means = [(1/len(self.mixtures))*np.clip(rv.pdf(self.x), a_min=0, a_max=8)\ 144 | for rv, w in self.mixtures[0]] 145 | for mix in self.mixtures[1:]: 146 | for c, (rv, w) in enumerate(mix): 147 | pdf_means[c] += (1/len(self.mixtures))*np.clip(rv.pdf(self.x), a_min=0, a_max=8) 148 | 149 | # Compute the stdev of the pdf of each component 150 | if len(self.mixtures) > 1: 151 | pdfs_sq_err_sum = [(np.clip(rv.pdf(self.x), a_min=0, a_max=8) - pdf_means[c])**2 \ 152 | for c, (rv, w) in enumerate(self.mixtures[0])] 153 | for mix in self.mixtures[1:]: 154 | for c, (rv, w) in enumerate(mix): 155 | pdfs_sq_err_sum[c] += (np.clip(rv.pdf(self.x), a_min=0, a_max=8) - pdf_means[c])**2 156 | pdf_stdevs = [np.sqrt(pdf_sq_err_sum)/(len(self.mixtures) - 1) \ 157 | for pdf_sq_err_sum in pdfs_sq_err_sum] 158 | 159 | # Plot the means of the pdfs 160 | fig, ax = plt.subplots() 161 | colors = ['r', 'g', 'b', 'c', 'm', 'y', 'k'] 162 | for c, (pdf_mean, color) in enumerate(zip(pdf_means, colors)): 163 | ax.plot(self.x, pdf_mean, c=color, label=f'BMM Component #{c}') 164 | ax.set_xlabel('Pixel value / $\\tau$') 165 | ax.set_ylabel('Probability Density') 166 | plt.legend() 167 | 168 | if len(self.mixtures) > 1: 169 | # # Plot the standard deviations of the pdfs 170 | # fig, ax = plt.subplots() 171 | # colors = ['r', 'g', 'b', 'c', 'm', 'y', 'k'] 172 | # max_stdev = 0 173 | # for c, (pdf_stdev, color) in enumerate(zip(pdf_stdevs, colors)): 174 | # ax.plot(self.x, pdf_stdev, c=color, label=f'Component #{c}') 175 | # max_stdev = max(max_stdev, max(pdf_stdev)) 176 | # ax.set_title('Standard Deviation of the\nProbability Density Functions\n' 177 | # 'of the fitted bimodal Beta Mixture Model') 178 | # ax.set_xlabel('Pixel value') 179 | # ax.set_ylabel('Standard Deviation') 180 | # ax.set_ylim([0, max_stdev]) 181 | # figs['std_bmm'] = fig 182 | # plt.close(fig) 183 | 184 | # Plot the KDE of the histogram of the threshold (the mean of last RV) 185 | thresholds = [mix[-1][0].mean() for mix in self.mixtures] 186 | thresholds = np.array(thresholds)[np.bitwise_not(np.isnan(thresholds))] 187 | kde = scipy.stats.gaussian_kde(thresholds.reshape(1, -1)) 188 | ax.plot(self.x, kde.pdf(self.x), 189 | '--', 190 | label='KDE of $\\tau$ selected by BMM method') 191 | ax.set_xlabel('Pixel value / $\\tau$') 192 | ax.set_ylabel('Probability Density') 193 | plt.legend() 194 | figs['bmm_stats'] = fig 195 | plt.close(fig) 196 | 197 | return figs 198 | 199 | def cluster(array, n_clusters, max_mask_pts=np.infty): 200 | """ 201 | Cluster a 2-D binary array. 202 | Applies a Gaussian Mixture Model on the positive elements of the array, 203 | and returns the number of clusters. 204 | 205 | :param array: Binary array. 206 | :param n_clusters: Number of clusters (Gaussians) to fit, 207 | :param max_mask_pts: Randomly subsample "max_pts" points 208 | from the array before fitting. 209 | :return: Centroids in the input array. 210 | """ 211 | 212 | array = np.array(array) 213 | 214 | assert array.ndim == 2 215 | 216 | coord = np.where(array > 0) 217 | y = coord[0].reshape((-1, 1)) 218 | x = coord[1].reshape((-1, 1)) 219 | c = np.concatenate((y, x), axis=1) 220 | if len(c) == 0: 221 | centroids = np.array([]) 222 | else: 223 | # Subsample our points randomly so it is faster 224 | if max_mask_pts != np.infty: 225 | n_pts = min(len(c), max_mask_pts) 226 | np.random.shuffle(c) 227 | c = c[:n_pts] 228 | 229 | # If the estimation is horrible, we cannot fit a GMM if n_components > n_samples 230 | n_components = max(min(n_clusters, x.size), 1) 231 | centroids = sklearn.mixture.GaussianMixture(n_components=n_components, 232 | n_init=1, 233 | covariance_type='full').\ 234 | fit(c).means_.astype(np.int) 235 | 236 | return centroids 237 | 238 | 239 | class RunningAverage(): 240 | 241 | def __init__(self, size): 242 | self.list = [] 243 | self.size = size 244 | 245 | def put(self, elem): 246 | if len(self.list) >= self.size: 247 | self.list.pop(0) 248 | self.list.append(elem) 249 | 250 | def pop(self): 251 | self.list.pop(0) 252 | 253 | @property 254 | def avg(self): 255 | return np.average(self.list) 256 | 257 | 258 | def overlay_heatmap(img, map, colormap=matplotlib.cm.viridis): 259 | """ 260 | Overlay a scalar map onto an image by using a heatmap 261 | 262 | :param img: RGB image (numpy array). 263 | Must be between 0 and 255. 264 | First dimension must be color. 265 | :param map: Scalar image (numpy array) 266 | Must be a 2D array between 0 and 1. 267 | :param colormap: Colormap to use to convert grayscale values 268 | to pseudo-color. 269 | :return: Heatmap on top of the original image in [0, 255] 270 | """ 271 | assert img.ndim == 3 272 | assert map.ndim == 2 273 | assert img.shape[0] == 3 274 | 275 | # Convert image to CHW->HWC 276 | img = img.transpose(1, 2, 0) 277 | 278 | # Generate pseudocolor 279 | heatmap = colormap(map)[:, :, :3] 280 | 281 | # Scale heatmap [0, 1] -> [0, 255] 282 | heatmap *= 255 283 | 284 | # Fusion! 285 | img_w_heatmap = (img + heatmap)/2 286 | 287 | # Convert output to HWC->CHW 288 | img_w_heatmap = img_w_heatmap.transpose(2, 0, 1) 289 | 290 | return img_w_heatmap 291 | 292 | 293 | def paint_circles(img, points, color='red', crosshair=False): 294 | """ 295 | Paint points as circles on top of an image. 296 | 297 | :param img: RGB image (numpy array). 298 | Must be between 0 and 255. 299 | First dimension must be color. 300 | :param centroids: List of centroids in (y, x) format. 301 | :param color: String of the color used to paint centroids. 302 | Default: 'red'. 303 | :param crosshair: Paint crosshair instead of circle. 304 | Default: False. 305 | :return: Image with painted circles centered on the points. 306 | First dimension is be color. 307 | """ 308 | 309 | if color == 'red': 310 | color = [255, 0, 0] 311 | elif color == 'white': 312 | color = [255, 255, 255] 313 | else: 314 | raise NotImplementedError(f'color {color} not implemented') 315 | 316 | points = points.round().astype(np.uint16) 317 | 318 | img = np.moveaxis(img, 0, 2).copy() 319 | if not crosshair: 320 | for y, x in points: 321 | img = cv2.circle(img, (x, y), 3, color, -1) 322 | else: 323 | for y, x in points: 324 | img = cv2.drawMarker(img, 325 | (x, y), 326 | color, cv2.MARKER_TILTED_CROSS, 7, 1, cv2.LINE_AA) 327 | img = np.moveaxis(img, 2, 0) 328 | 329 | return img 330 | 331 | 332 | def nothing(*args, **kwargs): 333 | """ A useless function that does nothing at all. """ 334 | pass 335 | 336 | 337 | """ 338 | Copyright ©right © (c) 2019 The Board of Trustees of Purdue University and the Purdue Research Foundation. 339 | All rights reserved. 340 | 341 | This software is covered by US patents and copyright. 342 | This source code is to be used for academic research purposes only, and no commercial use is allowed. 343 | 344 | For any questions, please contact Edward J. Delp (ace@ecn.purdue.edu) at Purdue University. 345 | 346 | Last Modified: 10/02/2019 347 | """ 348 | -------------------------------------------------------------------------------- /object-locator/get_image_size.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import print_function 4 | """ 5 | 6 | get_image_size.py 7 | ==================== 8 | 9 | :Name: get_image_size 10 | :Purpose: extract image dimensions given a file path 11 | 12 | :Author: Paulo Scardine (based on code from Emmanuel VAÏSSE) 13 | 14 | :Created: 26/09/2013 15 | :Copyright: (c) Paulo Scardine 2013 16 | :Licence: MIT 17 | 18 | """ 19 | import collections 20 | import json 21 | import os 22 | import struct 23 | 24 | FILE_UNKNOWN = "Sorry, don't know how to get size for this file." 25 | 26 | 27 | class UnknownImageFormat(Exception): 28 | pass 29 | 30 | 31 | types = collections.OrderedDict() 32 | BMP = types['BMP'] = 'BMP' 33 | GIF = types['GIF'] = 'GIF' 34 | ICO = types['ICO'] = 'ICO' 35 | JPEG = types['JPEG'] = 'JPEG' 36 | PNG = types['PNG'] = 'PNG' 37 | TIFF = types['TIFF'] = 'TIFF' 38 | 39 | image_fields = ['path', 'type', 'file_size', 'width', 'height'] 40 | 41 | 42 | class Image(collections.namedtuple('Image', image_fields)): 43 | 44 | def to_str_row(self): 45 | return ("%d\t%d\t%d\t%s\t%s" % ( 46 | self.width, 47 | self.height, 48 | self.file_size, 49 | self.type, 50 | self.path.replace('\t', '\\t'), 51 | )) 52 | 53 | def to_str_row_verbose(self): 54 | return ("%d\t%d\t%d\t%s\t%s\t##%s" % ( 55 | self.width, 56 | self.height, 57 | self.file_size, 58 | self.type, 59 | self.path.replace('\t', '\\t'), 60 | self)) 61 | 62 | def to_str_json(self, indent=None): 63 | return json.dumps(self._asdict(), indent=indent) 64 | 65 | 66 | def get_image_size(file_path): 67 | """ 68 | Return (width, height) for a given img file content - no external 69 | dependencies except the os and struct builtin modules 70 | """ 71 | img = get_image_metadata(file_path) 72 | return (img.width, img.height) 73 | 74 | 75 | def get_image_metadata(file_path): 76 | """ 77 | Return an `Image` object for a given img file content - no external 78 | dependencies except the os and struct builtin modules 79 | 80 | Args: 81 | file_path (str): path to an image file 82 | 83 | Returns: 84 | Image: (path, type, file_size, width, height) 85 | """ 86 | size = os.path.getsize(file_path) 87 | 88 | # be explicit with open arguments - we need binary mode 89 | with open(file_path, "rb") as input: 90 | height = -1 91 | width = -1 92 | data = input.read(26) 93 | msg = " raised while trying to decode as JPEG." 94 | 95 | if (size >= 10) and data[:6] in (b'GIF87a', b'GIF89a'): 96 | # GIFs 97 | imgtype = GIF 98 | w, h = struct.unpack("