From 047160f30ad125b67692e9ee223f8e637f5d351b Mon Sep 17 00:00:00 2001 From: BrandonHanx Date: Sat, 4 Feb 2023 17:11:38 +0000 Subject: [PATCH] [feat] first init with CUB200 --- .gitignore | 170 ++++++++++++++ .pre-commit-config.yaml | 47 ++++ README.md | 1 + args.py | 311 ++++++++++++++++++++++++++ datasets/.gitkeep | 0 docs/license_header.txt | 1 + log/log_train.txt | 81 +++++++ logs/.gitkeep | 0 main.py | 320 +++++++++++++++++++++++++++ src/__init__.py | 1 + src/data_manager.py | 163 ++++++++++++++ src/dataset_loader.py | 44 ++++ src/datasets/__init__.py | 21 ++ src/datasets/base.py | 55 +++++ src/datasets/cub200.py | 71 ++++++ src/datasets/vehicleid.py | 172 ++++++++++++++ src/datasets/veri.py | 96 ++++++++ src/eval_metrics.py | 129 +++++++++++ src/losses/__init__.py | 18 ++ src/losses/cross_entropy_loss.py | 43 ++++ src/losses/hard_mine_triplet_loss.py | 49 ++++ src/lr_schedulers.py | 23 ++ src/models/__init__.py | 20 ++ src/models/resnet.py | 298 +++++++++++++++++++++++++ src/optimizers.py | 85 +++++++ src/samplers.py | 89 ++++++++ src/transforms.py | 165 ++++++++++++++ src/utils/__init__.py | 1 + src/utils/avgmeter.py | 23 ++ src/utils/generaltools.py | 13 ++ src/utils/iotools.py | 35 +++ src/utils/loggers.py | 76 +++++++ src/utils/mean_and_std.py | 29 +++ src/utils/torchtools.py | 194 ++++++++++++++++ src/utils/visualtools.py | 72 ++++++ test.sh | 11 + train.sh | 15 ++ 37 files changed, 2942 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 README.md create mode 100644 args.py create mode 100644 datasets/.gitkeep create mode 100644 docs/license_header.txt create mode 100644 log/log_train.txt create mode 100644 logs/.gitkeep create mode 100644 main.py create mode 100644 src/__init__.py create mode 100644 src/data_manager.py create mode 100644 src/dataset_loader.py create mode 100644 src/datasets/__init__.py create mode 100644 src/datasets/base.py create mode 100644 src/datasets/cub200.py create mode 100644 src/datasets/vehicleid.py create mode 100644 src/datasets/veri.py create mode 100644 src/eval_metrics.py create mode 100644 src/losses/__init__.py create mode 100644 src/losses/cross_entropy_loss.py create mode 100644 src/losses/hard_mine_triplet_loss.py create mode 100644 src/lr_schedulers.py create mode 100644 src/models/__init__.py create mode 100644 src/models/resnet.py create mode 100644 src/optimizers.py create mode 100644 src/samplers.py create mode 100644 src/transforms.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/avgmeter.py create mode 100644 src/utils/generaltools.py create mode 100644 src/utils/iotools.py create mode 100644 src/utils/loggers.py create mode 100644 src/utils/mean_and_std.py create mode 100644 src/utils/torchtools.py create mode 100644 src/utils/visualtools.py create mode 100644 test.sh create mode 100644 train.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a501123 --- /dev/null +++ b/.gitignore @@ -0,0 +1,170 @@ +# Initially taken from Github's Python gitignore file + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# tests and logs +tests/fixtures/cached_*_text.txt +logs/* +lightning_logs/ +lang_code_data/ + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# vscode +.vs +.vscode + +# Pycharm +.idea + +# TF code +tensorflow_code + +# Models +proc_data + +# examples +runs +/runs_old +/wandb +/examples/runs +/examples/**/*.args +/examples/rag/sweep + +# data +data/* +serialization_dir + +# emacs +*.*~ +debug.env + +# vim +.*.swp + +#ctags +tags + +# .lock +*.lock + +# DS_Store (MacOS) +.DS_Store +# RL pipelines may produce mp4 outputs +*.mp4 + +# dependencies +/transformers + +datasets/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ba394d0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,47 @@ +exclude: 'build' + +default_language_version: + python: python3 + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.5.0 + hooks: + - id: trailing-whitespace + - id: check-ast + - id: check-merge-conflict + - id: check-added-large-files + args: ['--maxkb=500'] + - id: end-of-file-fixer + +- repo: https://github.com/pycqa/flake8 + rev: 3.7.9 + hooks: + - id: flake8 + exclude: (clip/|trainers/vision_benchmark/|lpclip) + args: + - "--max-line-length=88" + - "--ignore=E203,E501,W503,F401" + +- repo: https://github.com/asottile/pyupgrade + rev: v2.2.1 + hooks: + - id: pyupgrade + args: ['--py36-plus'] + +- repo: https://github.com/omnilib/ufmt + rev: v1.3.0 + hooks: + - id: ufmt + additional_dependencies: + - black == 21.9b0 + - usort == 0.6.4 + +- repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.1.7 + hooks: + - id: insert-license + files: \.py$ + args: + - --license-filepath + - docs/license_header.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f26892 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Course Work EEEM071 (2023 Spring) diff --git a/args.py b/args.py new file mode 100644 index 0000000..1014524 --- /dev/null +++ b/args.py @@ -0,0 +1,311 @@ +# Copyright (c) EEEM071, University of Surrey + +import argparse + + +def argument_parser(): + parser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + + # ************************************************************ + # Datasets (general) + # ************************************************************ + parser.add_argument( + "--root", type=str, default="./datasets", help="root path to data directory" + ) + parser.add_argument( + "-s", + "--source-names", + type=str, + required=True, + nargs="+", + help="source dataset for training(delimited by space)", + ) + parser.add_argument( + "-t", + "--target-names", + type=str, + required=True, + nargs="+", + help="target dataset for testing(delimited by space)", + ) + parser.add_argument( + "-j", + "--workers", + default=4, + type=int, + help="number of data loading workers (tips: 4 or 8 times number of gpus)", + ) + # split-id not used + parser.add_argument( + "--split-id", type=int, default=0, help="split index (note: 0-based)" + ) + parser.add_argument("--height", type=int, default=128, help="height of an image") + parser.add_argument("--width", type=int, default=256, help="width of an image") + parser.add_argument( + "--train-sampler", + type=str, + default="RandomSampler", + help="sampler for trainloader", + ) + + # ************************************************************ + # Data augmentation + # ************************************************************ + parser.add_argument( + "--random-erase", + action="store_true", + help="use random erasing for data augmentation", + ) + parser.add_argument( + "--color-jitter", + action="store_true", + help="randomly change the brightness, contrast and saturation", + ) + parser.add_argument( + "--color-aug", + action="store_true", + help="randomly alter the intensities of RGB channels", + ) + + # ************************************************************ + # Optimization options + # ************************************************************ + parser.add_argument( + "--optim", + type=str, + default="adam", + help="optimization algorithm (see optimizers.py)", + ) + parser.add_argument( + "--lr", default=0.0003, type=float, help="initial learning rate" + ) + parser.add_argument( + "--weight-decay", default=5e-04, type=float, help="weight decay" + ) + # sgd + parser.add_argument( + "--momentum", + default=0.9, + type=float, + help="momentum factor for sgd and rmsprop", + ) + parser.add_argument( + "--sgd-dampening", default=0, type=float, help="sgd's dampening for momentum" + ) + parser.add_argument( + "--sgd-nesterov", + action="store_true", + help="whether to enable sgd's Nesterov momentum", + ) + # rmsprop + parser.add_argument( + "--rmsprop-alpha", default=0.99, type=float, help="rmsprop's smoothing constant" + ) + # adam/amsgrad + parser.add_argument( + "--adam-beta1", + default=0.9, + type=float, + help="exponential decay rate for adam's first moment", + ) + parser.add_argument( + "--adam-beta2", + default=0.999, + type=float, + help="exponential decay rate for adam's second moment", + ) + + # ************************************************************ + # Training hyperparameters + # ************************************************************ + parser.add_argument( + "--max-epoch", default=60, type=int, help="maximum epochs to run" + ) + parser.add_argument( + "--start-epoch", + default=0, + type=int, + help="manual epoch number (useful when restart)", + ) + + parser.add_argument( + "--train-batch-size", default=32, type=int, help="training batch size" + ) + parser.add_argument( + "--test-batch-size", default=100, type=int, help="test batch size" + ) + + # ************************************************************ + # Learning rate scheduler options + # ************************************************************ + parser.add_argument( + "--lr-scheduler", + type=str, + default="multi_step", + help="learning rate scheduler (see lr_schedulers.py)", + ) + parser.add_argument( + "--stepsize", + default=[20, 40], + nargs="+", + type=int, + help="stepsize to decay learning rate", + ) + parser.add_argument("--gamma", default=0.1, type=float, help="learning rate decay") + + # ************************************************************ + # Cross entropy loss-specific setting + # ************************************************************ + parser.add_argument( + "--label-smooth", + action="store_true", + help="use label smoothing regularizer in cross entropy loss", + ) + + # ************************************************************ + # Hard triplet loss-specific setting + # ************************************************************ + parser.add_argument( + "--margin", type=float, default=0.3, help="margin for triplet loss" + ) + parser.add_argument( + "--num-instances", type=int, default=4, help="number of instances per identity" + ) + parser.add_argument( + "--lambda-xent", + type=float, + default=1, + help="weight to balance cross entropy loss", + ) + parser.add_argument( + "--lambda-htri", + type=float, + default=1, + help="weight to balance hard triplet loss", + ) + + # ************************************************************ + # Architecture + # ************************************************************ + parser.add_argument("-a", "--arch", type=str, default="resnet50") + parser.add_argument( + "--no-pretrained", action="store_true", help="do not load pretrained weights" + ) + + # ************************************************************ + # Test settings + # ************************************************************ + parser.add_argument( + "--load-weights", + type=str, + default="", + help="load pretrained weights but ignore layers that don't match in size", + ) + parser.add_argument("--evaluate", action="store_true", help="evaluate only") + parser.add_argument( + "--eval-freq", + type=int, + default=-1, + help="evaluation frequency (set to -1 to test only in the end)", + ) + parser.add_argument( + "--start-eval", + type=int, + default=0, + help="start to evaluate after a specific epoch", + ) + parser.add_argument( + "--test_size", + type=int, + default=800, + help="test-size for vehicleID dataset, choices=[800,1600,2400]", + ) + parser.add_argument("--query-remove", type=bool, default=True) + # ************************************************************ + # Miscs + # ************************************************************ + parser.add_argument("--print-freq", type=int, default=10, help="print frequency") + parser.add_argument("--seed", type=int, default=1, help="manual seed") + parser.add_argument( + "--resume", + type=str, + default="", + metavar="PATH", + help="resume from a checkpoint", + ) + parser.add_argument( + "--save-dir", type=str, default="log", help="path to save log and model weights" + ) + parser.add_argument("--use-cpu", action="store_true", help="use cpu") + parser.add_argument( + "--gpu-devices", + default="0", + type=str, + help="gpu device ids for CUDA_VISIBLE_DEVICES", + ) + + parser.add_argument( + "--visualize-ranks", + action="store_true", + help="visualize ranked results, only available in evaluation mode", + ) + parser.add_argument( + "--use-avai-gpus", + action="store_true", + help="use available gpus instead of specified devices (useful when using managed clusters)", + ) + return parser + + +def dataset_kwargs(parsed_args): + """ + Build kwargs for ImageDataManager in data_manager.py from + the parsed command-line arguments. + """ + return { + "source_names": parsed_args.source_names, + "target_names": parsed_args.target_names, + "root": parsed_args.root, + "split_id": parsed_args.split_id, + "height": parsed_args.height, + "width": parsed_args.width, + "train_batch_size": parsed_args.train_batch_size, + "test_batch_size": parsed_args.test_batch_size, + "workers": parsed_args.workers, + "train_sampler": parsed_args.train_sampler, + "random_erase": parsed_args.random_erase, + "color_jitter": parsed_args.color_jitter, + "color_aug": parsed_args.color_aug, + } + + +def optimizer_kwargs(parsed_args): + """ + Build kwargs for optimizer in optimizers.py from + the parsed command-line arguments. + """ + return { + "optim": parsed_args.optim, + "lr": parsed_args.lr, + "weight_decay": parsed_args.weight_decay, + "momentum": parsed_args.momentum, + "sgd_dampening": parsed_args.sgd_dampening, + "sgd_nesterov": parsed_args.sgd_nesterov, + "rmsprop_alpha": parsed_args.rmsprop_alpha, + "adam_beta1": parsed_args.adam_beta1, + "adam_beta2": parsed_args.adam_beta2, + } + + +def lr_scheduler_kwargs(parsed_args): + """ + Build kwargs for lr_scheduler in lr_schedulers.py from + the parsed command-line arguments. + """ + return { + "lr_scheduler": parsed_args.lr_scheduler, + "stepsize": parsed_args.stepsize, + "gamma": parsed_args.gamma, + } diff --git a/datasets/.gitkeep b/datasets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/license_header.txt b/docs/license_header.txt new file mode 100644 index 0000000..29b0dfa --- /dev/null +++ b/docs/license_header.txt @@ -0,0 +1 @@ +Copyright (c) EEEM071, University of Surrey diff --git a/log/log_train.txt b/log/log_train.txt new file mode 100644 index 0000000..9a073e7 --- /dev/null +++ b/log/log_train.txt @@ -0,0 +1,81 @@ +========== +Args:Namespace(adam_beta1=0.9, adam_beta2=0.999, arch='resnet50', color_aug=False, color_jitter=False, eval_freq=-1, evaluate=False, gamma=0.1, gpu_devices='0', height=128, label_smooth=False, lambda_htri=1, lambda_xent=1, load_weights='', lr=0.0003, lr_scheduler='multi_step', margin=0.3, max_epoch=60, momentum=0.9, no_pretrained=False, num_instances=4, optim='adam', print_freq=10, query_remove=True, random_erase=False, resume='', rmsprop_alpha=0.99, root='./datasets', save_dir='log', seed=1, sgd_dampening=0, sgd_nesterov=False, source_names=['cub200'], split_id=0, start_epoch=0, start_eval=0, stepsize=[20, 40], target_names=['cub200'], test_batch_size=100, test_size=800, train_batch_size=32, train_sampler='RandomSampler', use_avai_gpus=False, use_cpu=False, visualize_ranks=False, weight_decay=0.0005, width=256, workers=4) +========== +Currently using GPU 0 +Initializing image data manager +=> Initializing TRAIN (source) datasets +=> CUB200 loaded +Image Dataset statistics: + ---------------------------------------- + subset | # ids | # images | # cameras + ---------------------------------------- + train | 150 | 8822 | 1 + query | 50 | 2966 | 1 + gallery | 50 | 2966 | 1 + ---------------------------------------- +mean and std: tensor([0.0294, 0.2178, 0.1926]) tensor([0.7488, 0.7618, 0.8073]) +=> Initializing TEST (target) datasets +=> CUB200 loaded +Image Dataset statistics: + ---------------------------------------- + subset | # ids | # images | # cameras + ---------------------------------------- + train | 150 | 8822 | 1 + query | 50 | 2966 | 1 + gallery | 50 | 2966 | 1 + ---------------------------------------- + + + **************** Summary **************** + train names : ['cub200'] + # train datasets : 1 + # train ids : 150 + # train images : 8822 + # train cameras : 1 + test names : ['cub200'] + ***************************************** + + +Initializing model: resnet50 +Initialized model with pretrained weights from https://download.pytorch.org/models/resnet50-19c8e357.pth +Model size: 23.508 M +=> Start training +Epoch: [1][10/275] Time 0.112 (0.219) Data 0.0001 (0.0351) Xent 4.9875 (5.1005) Htri 0.2562 (0.6489) Acc 9.38 (3.12) +Epoch: [1][20/275] Time 0.109 (0.164) Data 0.0001 (0.0176) Xent 4.6453 (5.1278) Htri 0.4972 (0.6617) Acc 0.00 (2.50) +Epoch: [1][30/275] Time 0.109 (0.147) Data 0.0001 (0.0118) Xent 4.6171 (5.0820) Htri 0.1247 (0.6937) Acc 9.38 (3.75) +Epoch: [1][40/275] Time 0.103 (0.137) Data 0.0001 (0.0089) Xent 4.8530 (5.0217) Htri 0.8356 (0.6088) Acc 3.12 (3.83) +Epoch: [1][50/275] Time 0.111 (0.131) Data 0.0001 (0.0071) Xent 4.6261 (4.9466) Htri 0.2209 (0.5677) Acc 3.12 (4.31) +Epoch: [1][60/275] Time 0.097 (0.127) Data 0.0001 (0.0060) Xent 4.5369 (4.8810) Htri 0.7129 (0.5872) Acc 15.62 (5.16) +Epoch: [1][70/275] Time 0.103 (0.123) Data 0.0001 (0.0051) Xent 4.6418 (4.8348) Htri 0.4883 (0.5809) Acc 0.00 (5.00) +Epoch: [1][80/275] Time 0.116 (0.122) Data 0.0001 (0.0045) Xent 4.2960 (4.7728) Htri 0.6064 (0.5719) Acc 12.50 (5.51) +Epoch: [1][90/275] Time 0.113 (0.121) Data 0.0003 (0.0040) Xent 4.1678 (4.7086) Htri 0.0000 (0.5806) Acc 15.62 (5.87) +Epoch: [1][100/275] Time 0.112 (0.120) Data 0.0001 (0.0036) Xent 4.1637 (4.6597) Htri 0.3568 (0.5836) Acc 3.12 (6.03) +Epoch: [1][110/275] Time 0.114 (0.119) Data 0.0001 (0.0033) Xent 3.8771 (4.6078) Htri 0.7246 (0.5906) Acc 18.75 (6.42) +Epoch: [1][120/275] Time 0.114 (0.119) Data 0.0001 (0.0031) Xent 3.8187 (4.5566) Htri 1.1779 (0.5774) Acc 18.75 (6.93) +Epoch: [1][130/275] Time 0.112 (0.118) Data 0.0002 (0.0028) Xent 3.7031 (4.5054) Htri 0.3979 (0.5862) Acc 18.75 (7.21) +Epoch: [1][140/275] Time 0.118 (0.118) Data 0.0001 (0.0026) Xent 3.6030 (4.4604) Htri 0.2055 (0.6043) Acc 15.62 (7.61) +Epoch: [1][150/275] Time 0.114 (0.118) Data 0.0001 (0.0025) Xent 3.9010 (4.4178) Htri 0.5096 (0.6072) Acc 6.25 (8.00) +Epoch: [1][160/275] Time 0.104 (0.117) Data 0.0001 (0.0023) Xent 3.5954 (4.3784) Htri 0.0000 (0.6039) Acc 12.50 (8.20) +Epoch: [1][170/275] Time 0.099 (0.117) Data 0.0001 (0.0022) Xent 3.5237 (4.3351) Htri 0.3550 (0.5938) Acc 18.75 (8.55) +Epoch: [1][180/275] Time 0.100 (0.116) Data 0.0001 (0.0021) Xent 3.4056 (4.2937) Htri 0.0299 (0.5833) Acc 18.75 (8.92) +Epoch: [1][190/275] Time 0.114 (0.116) Data 0.0001 (0.0020) Xent 3.2723 (4.2508) Htri 0.0000 (0.5914) Acc 12.50 (9.31) +Epoch: [1][200/275] Time 0.107 (0.115) Data 0.0002 (0.0019) Xent 3.3283 (4.2121) Htri 0.5173 (0.5965) Acc 9.38 (9.38) +Epoch: [1][210/275] Time 0.103 (0.115) Data 0.0002 (0.0018) Xent 3.5439 (4.1806) Htri 0.2218 (0.6067) Acc 9.38 (9.66) +Epoch: [1][220/275] Time 0.105 (0.115) Data 0.0002 (0.0017) Xent 3.5618 (4.1518) Htri 0.5839 (0.6050) Acc 12.50 (9.94) +Epoch: [1][230/275] Time 0.115 (0.115) Data 0.0001 (0.0017) Xent 3.6304 (4.1222) Htri 0.5369 (0.6083) Acc 12.50 (10.16) +Epoch: [1][240/275] Time 0.116 (0.115) Data 0.0001 (0.0016) Xent 3.6681 (4.0990) Htri 1.0814 (0.6118) Acc 9.38 (10.43) +Epoch: [1][250/275] Time 0.100 (0.115) Data 0.0001 (0.0015) Xent 3.4984 (4.0730) Htri 0.5441 (0.6093) Acc 15.62 (10.69) +Epoch: [1][260/275] Time 0.118 (0.114) Data 0.0001 (0.0015) Xent 3.4363 (4.0426) Htri 0.5002 (0.6080) Acc 15.62 (11.09) +Epoch: [1][270/275] Time 0.110 (0.114) Data 0.0001 (0.0014) Xent 3.0214 (4.0132) Htri 0.3009 (0.6091) Acc 34.38 (11.46) +Epoch: [2][10/275] Time 0.103 (0.148) Data 0.0001 (0.0359) Xent 2.9078 (2.8817) Htri 0.2648 (0.5449) Acc 28.12 (31.25) +Epoch: [2][20/275] Time 0.104 (0.130) Data 0.0001 (0.0180) Xent 2.9008 (2.9231) Htri 0.2826 (0.4910) Acc 18.75 (27.19) +Epoch: [2][30/275] Time 0.115 (0.126) Data 0.0002 (0.0121) Xent 2.7656 (2.8876) Htri 0.6467 (0.5159) Acc 25.00 (27.08) +Epoch: [2][40/275] Time 0.106 (0.122) Data 0.0002 (0.0091) Xent 3.2782 (2.9161) Htri 1.5066 (0.6020) Acc 15.62 (26.41) +Epoch: [2][50/275] Time 0.121 (0.120) Data 0.0001 (0.0073) Xent 3.0653 (2.9409) Htri 1.1789 (0.6038) Acc 28.12 (25.75) +Epoch: [2][60/275] Time 0.100 (0.119) Data 0.0001 (0.0061) Xent 3.1163 (2.9443) Htri 0.6568 (0.5875) Acc 18.75 (25.99) +Epoch: [2][70/275] Time 0.101 (0.118) Data 0.0001 (0.0053) Xent 2.7864 (2.9360) Htri 0.2475 (0.5587) Acc 34.38 (26.12) +Epoch: [2][80/275] Time 0.099 (0.117) Data 0.0001 (0.0046) Xent 2.6869 (2.9275) Htri 0.0857 (0.5732) Acc 31.25 (25.90) +Epoch: [2][90/275] Time 0.106 (0.116) Data 0.0001 (0.0041) Xent 3.0816 (2.9158) Htri 0.8055 (0.5577) Acc 12.50 (25.87) +Epoch: [2][100/275] Time 0.099 (0.115) Data 0.0001 (0.0037) Xent 2.6633 (2.9043) Htri 0.8611 (0.5612) Acc 18.75 (26.00) +Epoch: [2][110/275] Time 0.114 (0.115) Data 0.0001 (0.0034) Xent 2.7600 (2.9018) Htri 0.3967 (0.5705) Acc 37.50 (25.85) +Epoch: [2][120/275] Time 0.114 (0.115) Data 0.0002 (0.0031) Xent 2.8759 (2.8960) Htri 1.2093 (0.5686) Acc 18.75 (25.83) diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..cb08cac --- /dev/null +++ b/main.py @@ -0,0 +1,320 @@ +# Copyright (c) EEEM071, University of Surrey + +import datetime +import os +import os.path as osp +import sys +import time +import warnings + +import numpy as np +import torch +import torch.backends.cudnn as cudnn +import torch.nn as nn +from args import argument_parser, dataset_kwargs, optimizer_kwargs, lr_scheduler_kwargs +from src import models +from src.data_manager import ImageDataManager +from src.eval_metrics import evaluate +from src.losses import CrossEntropyLoss, TripletLoss, DeepSupervision +from src.lr_schedulers import init_lr_scheduler +from src.optimizers import init_optimizer +from src.utils.avgmeter import AverageMeter +from src.utils.generaltools import set_random_seed +from src.utils.iotools import check_isfile +from src.utils.loggers import Logger, RankLogger +from src.utils.torchtools import ( + count_num_param, + accuracy, + load_pretrained_weights, + save_checkpoint, + resume_from_checkpoint, +) +from src.utils.visualtools import visualize_ranked_results + +# global variables +parser = argument_parser() +args = parser.parse_args() + + +def main(): + global args + + set_random_seed(args.seed) + if not args.use_avai_gpus: + os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu_devices + use_gpu = torch.cuda.is_available() + if args.use_cpu: + use_gpu = False + log_name = "log_test.txt" if args.evaluate else "log_train.txt" + sys.stdout = Logger(osp.join(args.save_dir, log_name)) + print(f"==========\nArgs:{args}\n==========") + + if use_gpu: + print(f"Currently using GPU {args.gpu_devices}") + cudnn.benchmark = True + else: + warnings.warn("Currently using CPU, however, GPU is highly recommended") + + print("Initializing image data manager") + dm = ImageDataManager(use_gpu, **dataset_kwargs(args)) + trainloader, testloader_dict = dm.return_dataloaders() + + print(f"Initializing model: {args.arch}") + model = models.init_model( + name=args.arch, + num_classes=dm.num_train_pids, + loss={"xent", "htri"}, + pretrained=not args.no_pretrained, + use_gpu=use_gpu, + ) + print("Model size: {:.3f} M".format(count_num_param(model))) + + if args.load_weights and check_isfile(args.load_weights): + load_pretrained_weights(model, args.load_weights) + + model = nn.DataParallel(model).cuda() if use_gpu else model + + criterion_xent = CrossEntropyLoss( + num_classes=dm.num_train_pids, use_gpu=use_gpu, label_smooth=args.label_smooth + ) + criterion_htri = TripletLoss(margin=args.margin) + optimizer = init_optimizer(model, **optimizer_kwargs(args)) + scheduler = init_lr_scheduler(optimizer, **lr_scheduler_kwargs(args)) + + if args.resume and check_isfile(args.resume): + args.start_epoch = resume_from_checkpoint( + args.resume, model, optimizer=optimizer + ) + + if args.evaluate: + print("Evaluate only") + + for name in args.target_names: + print(f"Evaluating {name} ...") + queryloader = testloader_dict[name]["query"] + galleryloader = testloader_dict[name]["gallery"] + distmat = test( + model, queryloader, galleryloader, use_gpu, return_distmat=True + ) + + if args.visualize_ranks: + visualize_ranked_results( + distmat, + dm.return_testdataset_by_name(name), + save_dir=osp.join(args.save_dir, "ranked_results", name), + topk=20, + ) + return + + time_start = time.time() + ranklogger = RankLogger(args.source_names, args.target_names) + print("=> Start training") + """ + if args.fixbase_epoch > 0: + print('Train {} for {} epochs while keeping other layers frozen'.format(args.open_layers, args.fixbase_epoch)) + initial_optim_state = optimizer.state_dict() + + for epoch in range(args.fixbase_epoch): + train(epoch, model, criterion_xent, criterion_htri, optimizer, trainloader, use_gpu, fixbase=True) + + print('Done. All layers are open to train for {} epochs'.format(args.max_epoch)) + optimizer.load_state_dict(initial_optim_state) + """ + for epoch in range(args.start_epoch, args.max_epoch): + train( + epoch, + model, + criterion_xent, + criterion_htri, + optimizer, + trainloader, + use_gpu, + ) + + scheduler.step() + + if ( + (epoch + 1) > args.start_eval + and args.eval_freq > 0 + and (epoch + 1) % args.eval_freq == 0 + or (epoch + 1) == args.max_epoch + ): + print("=> Test") + + for name in args.target_names: + print(f"Evaluating {name} ...") + queryloader = testloader_dict[name]["query"] + galleryloader = testloader_dict[name]["gallery"] + rank1 = test(model, queryloader, galleryloader, use_gpu) + ranklogger.write(name, epoch + 1, rank1) + + save_checkpoint( + { + "state_dict": model.state_dict(), + "rank1": rank1, + "epoch": epoch + 1, + "arch": args.arch, + "optimizer": optimizer.state_dict(), + }, + args.save_dir, + ) + + elapsed = round(time.time() - time_start) + elapsed = str(datetime.timedelta(seconds=elapsed)) + print(f"Elapsed {elapsed}") + ranklogger.show_summary() + + +def train( + epoch, model, criterion_xent, criterion_htri, optimizer, trainloader, use_gpu +): + xent_losses = AverageMeter() + htri_losses = AverageMeter() + accs = AverageMeter() + batch_time = AverageMeter() + data_time = AverageMeter() + + model.train() + for p in model.parameters(): + p.requires_grad = True # open all layers + + end = time.time() + for batch_idx, (imgs, pids, _, _) in enumerate(trainloader): + data_time.update(time.time() - end) + + if use_gpu: + imgs, pids = imgs.cuda(), pids.cuda() + + outputs, features = model(imgs) + if isinstance(outputs, (tuple, list)): + xent_loss = DeepSupervision(criterion_xent, outputs, pids) + else: + xent_loss = criterion_xent(outputs, pids) + + if isinstance(features, (tuple, list)): + htri_loss = DeepSupervision(criterion_htri, features, pids) + else: + htri_loss = criterion_htri(features, pids) + + loss = args.lambda_xent * xent_loss + args.lambda_htri * htri_loss + optimizer.zero_grad() + loss.backward() + optimizer.step() + + batch_time.update(time.time() - end) + + xent_losses.update(xent_loss.item(), pids.size(0)) + htri_losses.update(htri_loss.item(), pids.size(0)) + accs.update(accuracy(outputs, pids)[0]) + + if (batch_idx + 1) % args.print_freq == 0: + print( + "Epoch: [{0}][{1}/{2}]\t" + "Time {batch_time.val:.3f} ({batch_time.avg:.3f})\t" + "Data {data_time.val:.4f} ({data_time.avg:.4f})\t" + "Xent {xent.val:.4f} ({xent.avg:.4f})\t" + "Htri {htri.val:.4f} ({htri.avg:.4f})\t" + "Acc {acc.val:.2f} ({acc.avg:.2f})\t".format( + epoch + 1, + batch_idx + 1, + len(trainloader), + batch_time=batch_time, + data_time=data_time, + xent=xent_losses, + htri=htri_losses, + acc=accs, + ) + ) + + end = time.time() + + +def test( + model, + queryloader, + galleryloader, + use_gpu, + ranks=[1, 5, 10, 20], + return_distmat=False, +): + batch_time = AverageMeter() + + model.eval() + + with torch.no_grad(): + qf, q_pids, q_camids = [], [], [] + for batch_idx, (imgs, pids, camids, _) in enumerate(queryloader): + if use_gpu: + imgs = imgs.cuda() + + end = time.time() + features = model(imgs) + batch_time.update(time.time() - end) + + features = features.data.cpu() + qf.append(features) + q_pids.extend(pids) + q_camids.extend(camids) + qf = torch.cat(qf, 0) + q_pids = np.asarray(q_pids) + q_camids = np.asarray(q_camids) + + print( + "Extracted features for query set, obtained {}-by-{} matrix".format( + qf.size(0), qf.size(1) + ) + ) + + gf, g_pids, g_camids = [], [], [] + for batch_idx, (imgs, pids, camids, _) in enumerate(galleryloader): + if use_gpu: + imgs = imgs.cuda() + + end = time.time() + features = model(imgs) + batch_time.update(time.time() - end) + + features = features.data.cpu() + gf.append(features) + g_pids.extend(pids) + g_camids.extend(camids) + gf = torch.cat(gf, 0) + g_pids = np.asarray(g_pids) + g_camids = np.asarray(g_camids) + + print( + "Extracted features for gallery set, obtained {}-by-{} matrix".format( + gf.size(0), gf.size(1) + ) + ) + + print( + f"=> BatchTime(s)/BatchSize(img): {batch_time.avg:.3f}/{args.test_batch_size}" + ) + + m, n = qf.size(0), gf.size(0) + distmat = ( + torch.pow(qf, 2).sum(dim=1, keepdim=True).expand(m, n) + + torch.pow(gf, 2).sum(dim=1, keepdim=True).expand(n, m).t() + ) + distmat.addmm_(qf, gf.t(), beta=1, alpha=-2) + distmat = distmat.numpy() + + print("Computing CMC and mAP") + # cmc, mAP = evaluate(distmat, q_pids, g_pids, q_camids, g_camids, args.target_names) + cmc, mAP = evaluate(distmat, q_pids, g_pids, q_camids, g_camids) + + print("Results ----------") + print(f"mAP: {mAP:.1%}") + print("CMC curve") + for r in ranks: + print("Rank-{:<3}: {:.1%}".format(r, cmc[r - 1])) + print("------------------") + + if return_distmat: + return distmat + return cmc[0] + + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..418256f --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# Copyright (c) EEEM071, University of Surrey diff --git a/src/data_manager.py b/src/data_manager.py new file mode 100644 index 0000000..aa7acf2 --- /dev/null +++ b/src/data_manager.py @@ -0,0 +1,163 @@ +# Copyright (c) EEEM071, University of Surrey + +from torch.utils.data import DataLoader + +from .dataset_loader import ImageDataset +from .datasets import init_imgreid_dataset +from .samplers import build_train_sampler +from .transforms import build_transforms +from .utils.mean_and_std import get_mean_and_std, calculate_mean_and_std + + +class BaseDataManager: + def __init__( + self, + use_gpu, + source_names, + target_names, + root="datasets", + height=128, + width=256, + train_batch_size=32, + test_batch_size=100, + workers=4, + train_sampler="", + random_erase=False, # use random erasing for data augmentation + color_jitter=False, # randomly change the brightness, contrast and saturation + color_aug=False, # randomly alter the intensities of RGB channels + num_instances=4, # number of instances per identity (for RandomIdentitySampler) + **kwargs, + ): + self.use_gpu = use_gpu + self.source_names = source_names + self.target_names = target_names + self.root = root + self.height = height + self.width = width + self.train_batch_size = train_batch_size + self.test_batch_size = test_batch_size + self.workers = workers + self.train_sampler = train_sampler + self.random_erase = random_erase + self.color_jitter = color_jitter + self.color_aug = color_aug + self.num_instances = num_instances + + transform_train, transform_test = build_transforms( + self.height, + self.width, + random_erase=self.random_erase, + color_jitter=self.color_jitter, + color_aug=self.color_aug, + ) + self.transform_train = transform_train + self.transform_test = transform_test + + @property + def num_train_pids(self): + return self._num_train_pids + + @property + def num_train_cams(self): + return self._num_train_cams + + def return_dataloaders(self): + """ + Return trainloader and testloader dictionary + """ + return self.trainloader, self.testloader_dict + + def return_testdataset_by_name(self, name): + """ + Return query and gallery, each containing a list of (img_path, pid, camid). + """ + return ( + self.testdataset_dict[name]["query"], + self.testdataset_dict[name]["gallery"], + ) + + +class ImageDataManager(BaseDataManager): + """ + Vehicle-ReID data manager + """ + + def __init__(self, use_gpu, source_names, target_names, **kwargs): + super().__init__(use_gpu, source_names, target_names, **kwargs) + + print("=> Initializing TRAIN (source) datasets") + train = [] + self._num_train_pids = 0 + self._num_train_cams = 0 + + for name in self.source_names: + dataset = init_imgreid_dataset(root=self.root, name=name) + + for img_path, pid, camid in dataset.train: + pid += self._num_train_pids + camid += self._num_train_cams + train.append((img_path, pid, camid)) + + self._num_train_pids += dataset.num_train_pids + self._num_train_cams += dataset.num_train_cams + + self.train_sampler = build_train_sampler( + train, + self.train_sampler, + train_batch_size=self.train_batch_size, + num_instances=self.num_instances, + ) + self.trainloader = DataLoader( + ImageDataset(train, transform=self.transform_train), + sampler=self.train_sampler, + batch_size=self.train_batch_size, + shuffle=False, + num_workers=self.workers, + pin_memory=self.use_gpu, + drop_last=True, + ) + mean, std = calculate_mean_and_std(self.trainloader, len(train)) + print("mean and std:", mean, std) + + print("=> Initializing TEST (target) datasets") + self.testloader_dict = { + name: {"query": None, "gallery": None} for name in target_names + } + self.testdataset_dict = { + name: {"query": None, "gallery": None} for name in target_names + } + + for name in self.target_names: + dataset = init_imgreid_dataset(root=self.root, name=name) + + self.testloader_dict[name]["query"] = DataLoader( + ImageDataset(dataset.query, transform=self.transform_test), + batch_size=self.test_batch_size, + shuffle=False, + num_workers=self.workers, + pin_memory=self.use_gpu, + drop_last=False, + ) + + self.testloader_dict[name]["gallery"] = DataLoader( + ImageDataset(dataset.gallery, transform=self.transform_test), + batch_size=self.test_batch_size, + shuffle=False, + num_workers=self.workers, + pin_memory=self.use_gpu, + drop_last=False, + ) + + self.testdataset_dict[name]["query"] = dataset.query + self.testdataset_dict[name]["gallery"] = dataset.gallery + + print("\n") + print(" **************** Summary ****************") + print(f" train names : {self.source_names}") + print(" # train datasets : {}".format(len(self.source_names))) + print(f" # train ids : {self.num_train_pids}") + print(" # train images : {}".format(len(train))) + print(f" # train cameras : {self.num_train_cams}") + print(f" test names : {self.target_names}") + print(" *****************************************") + print("\n") diff --git a/src/dataset_loader.py b/src/dataset_loader.py new file mode 100644 index 0000000..8978beb --- /dev/null +++ b/src/dataset_loader.py @@ -0,0 +1,44 @@ +# Copyright (c) EEEM071, University of Surrey + +import os.path as osp + +from PIL import Image +from torch.utils.data import Dataset + + +def read_image(img_path): + """Keep reading image until succeed. + This can avoid IOError incurred by heavy IO process.""" + got_img = False + if not osp.exists(img_path): + raise OSError(f"{img_path} does not exist") + while not got_img: + try: + img = Image.open(img_path).convert("RGB") + got_img = True + except OSError: + print( + f'IOError incurred when reading "{img_path}". Will redo. Don\'t worry. Just chill.' + ) + pass + return img + + +class ImageDataset(Dataset): + """Image Person ReID Dataset""" + + def __init__(self, dataset, transform=None): + self.dataset = dataset + self.transform = transform + + def __len__(self): + return len(self.dataset) + + def __getitem__(self, index): + img_path, pid, camid = self.dataset[index] + img = read_image(img_path) + + if self.transform is not None: + img = self.transform(img) + + return img, pid, camid, img_path diff --git a/src/datasets/__init__.py b/src/datasets/__init__.py new file mode 100644 index 0000000..166b6ed --- /dev/null +++ b/src/datasets/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) EEEM071, University of Surrey + +from .cub200 import CUB200 +from .vehicleid import VehicleID +from .veri import VeRi + +__imgreid_factory = { + "veri": VeRi, + "vehicleID": VehicleID, + "cub200": CUB200, +} + + +def init_imgreid_dataset(name, **kwargs): + if name not in list(__imgreid_factory.keys()): + raise KeyError( + 'Invalid dataset, got "{}", but expected to be one of {}'.format( + name, list(__imgreid_factory.keys()) + ) + ) + return __imgreid_factory[name](**kwargs) diff --git a/src/datasets/base.py b/src/datasets/base.py new file mode 100644 index 0000000..6a43ed6 --- /dev/null +++ b/src/datasets/base.py @@ -0,0 +1,55 @@ +# Copyright (c) EEEM071, University of Surrey + +import os.path as osp + + +class BaseDataset: + """ + Base class of reid dataset + """ + + def __init__(self, root): + self.root = osp.expanduser(root) + + def get_imagedata_info(self, data): + pids, cams = [], [] + for _, pid, camid in data: + pids += [pid] + cams += [camid] + pids = set(pids) + cams = set(cams) + num_pids = len(pids) + num_cams = len(cams) + num_imgs = len(data) + return num_pids, num_imgs, num_cams + + def print_dataset_statistics(self): + raise NotImplementedError + + +class BaseImageDataset(BaseDataset): + """ + Base class of image reid dataset + """ + + def print_dataset_statistics(self, train, query, gallery): + num_train_pids, num_train_imgs, num_train_cams = self.get_imagedata_info(train) + num_query_pids, num_query_imgs, num_query_cams = self.get_imagedata_info(query) + num_gallery_pids, num_gallery_imgs, num_gallery_cams = self.get_imagedata_info( + gallery + ) + + print("Image Dataset statistics:") + print(" ----------------------------------------") + print(" subset | # ids | # images | # cameras") + print(" ----------------------------------------") + print( + f" train | {num_train_pids:5d} | {num_train_imgs:8d} | {num_train_cams:9d}" + ) + print( + f" query | {num_query_pids:5d} | {num_query_imgs:8d} | {num_query_cams:9d}" + ) + print( + f" gallery | {num_gallery_pids:5d} | {num_gallery_imgs:8d} | {num_gallery_cams:9d}" + ) + print(" ----------------------------------------") diff --git a/src/datasets/cub200.py b/src/datasets/cub200.py new file mode 100644 index 0000000..20ff982 --- /dev/null +++ b/src/datasets/cub200.py @@ -0,0 +1,71 @@ +# Copyright (c) EEEM071, University of Surrey + +import glob +import os.path as osp +import re + +from .base import BaseImageDataset + + +class CUB200(BaseImageDataset): + + dataset_dir = "CUB200" + + def __init__(self, root="datasets", verbose=True, **kwargs): + super().__init__(root) + self.dataset_dir = osp.join(self.root, self.dataset_dir) + self.train_dir = osp.join(self.dataset_dir, "train") + self.query_dir = osp.join(self.dataset_dir, "query") + self.gallery_dir = osp.join(self.dataset_dir, "gallery") + + self.check_before_run() + + train = self.process_dir(self.train_dir, relabel=True) + query = self.process_dir(self.query_dir, relabel=False) + gallery = self.process_dir(self.gallery_dir, relabel=False) + + if verbose: + print("=> CUB200 loaded") + self.print_dataset_statistics(train, query, gallery) + + self.train = train + self.query = query + self.gallery = gallery + + ( + self.num_train_pids, + self.num_train_imgs, + self.num_train_cams, + ) = self.get_imagedata_info(self.train) + ( + self.num_query_pids, + self.num_query_imgs, + self.num_query_cams, + ) = self.get_imagedata_info(self.query) + ( + self.num_gallery_pids, + self.num_gallery_imgs, + self.num_gallery_cams, + ) = self.get_imagedata_info(self.gallery) + + def check_before_run(self): + """Check if all files are available before going deeper""" + if not osp.exists(self.dataset_dir): + raise RuntimeError(f'"{self.dataset_dir}" is not available') + if not osp.exists(self.train_dir): + raise RuntimeError(f'"{self.train_dir}" is not available') + if not osp.exists(self.query_dir): + raise RuntimeError(f'"{self.query_dir}" is not available') + if not osp.exists(self.gallery_dir): + raise RuntimeError(f'"{self.gallery_dir}" is not available') + + def process_dir(self, dir_path, relabel=False): + img_paths = glob.glob(osp.join(dir_path, "*/*.jpg")) + + dataset = [] + # FIXME: pseudo camera id for CUB200 + for i, img_path in enumerate(img_paths): + pid = int(img_path.split("/")[-2].split(".")[0]) - 1 + dataset.append((img_path, pid, i)) + + return dataset diff --git a/src/datasets/vehicleid.py b/src/datasets/vehicleid.py new file mode 100644 index 0000000..a17cea6 --- /dev/null +++ b/src/datasets/vehicleid.py @@ -0,0 +1,172 @@ +# Copyright (c) EEEM071, University of Surrey + +import os.path as osp +import random +from collections import defaultdict + +from .base import BaseImageDataset + + +class VehicleID(BaseImageDataset): + """ + VehicleID + + Reference: + @inproceedings{liu2016deep, + title={Deep Relative Distance Learning: Tell the Difference Between Similar Vehicles}, + author={Liu, Hongye and Tian, Yonghong and Wang, Yaowei and Pang, Lu and Huang, Tiejun}, + booktitle={Proceedings of the IEEE Conference on Computer Vision and Pattern Recognition}, + pages={2167--2175}, + year={2016}} + + Dataset statistics: + # train_list: 13164 vehicles for model training + # test_list_800: 800 vehicles for model testing(small test set in paper + # test_list_1600: 1600 vehicles for model testing(medium test set in paper + # test_list_2400: 2400 vehicles for model testing(large test set in paper + # test_list_3200: 3200 vehicles for model testing + # test_list_6000: 6000 vehicles for model testing + # test_list_13164: 13164 vehicles for model testing + """ + + dataset_dir = "VehicleID" + + def __init__(self, root="datasets", verbose=True, test_size=800, **kwargs): + super().__init__(root) + self.dataset_dir = osp.join(self.root, self.dataset_dir) + self.img_dir = osp.join(self.dataset_dir, "image") + self.split_dir = osp.join(self.dataset_dir, "train_test_split") + self.train_list = osp.join(self.split_dir, "train_list.txt") + self.test_size = test_size + + if self.test_size == 800: + self.test_list = osp.join(self.split_dir, "test_list_800.txt") + elif self.test_size == 1600: + self.test_list = osp.join(self.split_dir, "test_list_1600.txt") + elif self.test_size == 2400: + self.test_list = osp.join(self.split_dir, "test_list_2400.txt") + + print(self.test_list) + + self.check_before_run() + + train, query, gallery = self.process_split(relabel=True) + self.train = train + self.query = query + self.gallery = gallery + + if verbose: + print("=> VehicleID loaded") + self.print_dataset_statistics(train, query, gallery) + + ( + self.num_train_pids, + self.num_train_imgs, + self.num_train_cams, + ) = self.get_imagedata_info(self.train) + ( + self.num_query_pids, + self.num_query_imgs, + self.num_query_cams, + ) = self.get_imagedata_info(self.query) + ( + self.num_gallery_pids, + self.num_gallery_imgs, + self.num_gallery_cams, + ) = self.get_imagedata_info(self.gallery) + + def check_before_run(self): + """Check if all files are available before going deeper""" + if not osp.exists(self.dataset_dir): + raise RuntimeError(f'"{self.dataset_dir}" is not available') + if not osp.exists(self.split_dir): + raise RuntimeError(f'"{self.split_dir}" is not available') + if not osp.exists(self.train_list): + raise RuntimeError(f'"{self.train_list}" is not available') + if self.test_size not in [800, 1600, 2400]: + raise RuntimeError(f'"{self.test_size}" is not available') + if not osp.exists(self.test_list): + raise RuntimeError(f'"{self.test_list}" is not available') + + def get_pid2label(self, pids): + pid_container = set(pids) + pid2label = {pid: label for label, pid in enumerate(pid_container)} + return pid2label + + def parse_img_pids(self, nl_pairs, pid2label=None): + # il_pair is the pairs of img name and label + output = [] + for info in nl_pairs: + name = info[0] + pid = info[1] + if pid2label is not None: + pid = pid2label[pid] + camid = 1 # don't have camid information use 1 for all + img_path = osp.join(self.img_dir, name + ".jpg") + output.append((img_path, pid, camid)) + return output + + def process_split(self, relabel=False): + # read train paths + train_pid_dict = defaultdict(list) + + # 'train_list.txt' format: + # the first number is the number of image + # the second number is the id of vehicle + with open(self.train_list) as f_train: + train_data = f_train.readlines() + for data in train_data: + name, pid = data.split(" ") + pid = int(pid) + train_pid_dict[pid].append([name, pid]) + train_pids = list(train_pid_dict.keys()) + num_train_pids = len(train_pids) + assert num_train_pids == 13164, ( + "There should be 13164 vehicles for training," + " but but got {}, please check the data".format(num_train_pids) + ) + print(f"num of train ids: {num_train_pids}") + test_pid_dict = defaultdict(list) + with open(self.test_list) as f_test: + test_data = f_test.readlines() + for data in test_data: + name, pid = data.split(" ") + test_pid_dict[pid].append([name, pid]) + test_pids = list(test_pid_dict.keys()) + num_test_pids = len(test_pids) + assert num_test_pids == self.test_size, ( + "There should be {} vehicles for testing," + " but but got {}, please check the data".format( + self.test_size, num_test_pids + ) + ) + + train_data = [] + query_data = [] + gallery_data = [] + + # for train ids, all images are used in the train set. + for pid in train_pids: + imginfo = train_pid_dict[pid] # imginfo include image name and id + train_data.extend(imginfo) + + # for each test id, random choose one image for gallery + # and the other ones for query. + for pid in test_pids: + imginfo = test_pid_dict[pid] + sample = random.choice(imginfo) + imginfo.remove(sample) + query_data.extend(imginfo) + gallery_data.append(sample) + + if relabel: + train_pid2label = self.get_pid2label(train_pids) + else: + train_pid2label = None + for key, value in train_pid2label.items(): + print(f"{key}:{value}") + + train = self.parse_img_pids(train_data, train_pid2label) + query = self.parse_img_pids(query_data) + gallery = self.parse_img_pids(gallery_data) + return train, query, gallery diff --git a/src/datasets/veri.py b/src/datasets/veri.py new file mode 100644 index 0000000..5932e8d --- /dev/null +++ b/src/datasets/veri.py @@ -0,0 +1,96 @@ +# Copyright (c) EEEM071, University of Surrey + +import glob +import os.path as osp +import re + +from .base import BaseImageDataset + + +class VeRi(BaseImageDataset): + """ + VeRi + Reference: + Liu, X., Liu, W., Ma, H., Fu, H.: Large-scale vehicle re-identification in urban surveillance videos. In: IEEE % + International Conference on Multimedia and Expo. (2016) accepted. + + Dataset statistics: + # identities: 776 vehicles(576 for training and 200 for testing) + # images: 37778 (train) + 11579 (query) + """ + + dataset_dir = "VeRi" + + def __init__(self, root="datasets", verbose=True, **kwargs): + super().__init__(root) + self.dataset_dir = osp.join(self.root, self.dataset_dir) + self.train_dir = osp.join(self.dataset_dir, "image_train") + self.query_dir = osp.join(self.dataset_dir, "image_query") + self.gallery_dir = osp.join(self.dataset_dir, "image_test") + + self.check_before_run() + + train = self.process_dir(self.train_dir, relabel=True) + query = self.process_dir(self.query_dir, relabel=False) + gallery = self.process_dir(self.gallery_dir, relabel=False) + + if verbose: + print("=> VeRi loaded") + self.print_dataset_statistics(train, query, gallery) + + self.train = train + self.query = query + self.gallery = gallery + + ( + self.num_train_pids, + self.num_train_imgs, + self.num_train_cams, + ) = self.get_imagedata_info(self.train) + ( + self.num_query_pids, + self.num_query_imgs, + self.num_query_cams, + ) = self.get_imagedata_info(self.query) + ( + self.num_gallery_pids, + self.num_gallery_imgs, + self.num_gallery_cams, + ) = self.get_imagedata_info(self.gallery) + + def check_before_run(self): + """Check if all files are available before going deeper""" + if not osp.exists(self.dataset_dir): + raise RuntimeError(f'"{self.dataset_dir}" is not available') + if not osp.exists(self.train_dir): + raise RuntimeError(f'"{self.train_dir}" is not available') + if not osp.exists(self.query_dir): + raise RuntimeError(f'"{self.query_dir}" is not available') + if not osp.exists(self.gallery_dir): + raise RuntimeError(f'"{self.gallery_dir}" is not available') + + def process_dir(self, dir_path, relabel=False): + img_paths = glob.glob(osp.join(dir_path, "*.jpg")) + pattern = re.compile(r"([-\d]+)_c([-\d]+)") + + pid_container = set() + for img_path in img_paths: + pid, _ = map(int, pattern.search(img_path).groups()) + if pid == -1: + continue # junk images are just ignored + pid_container.add(pid) + pid2label = {pid: label for label, pid in enumerate(pid_container)} + + dataset = [] + for img_path in img_paths: + pid, camid = map(int, pattern.search(img_path).groups()) + if pid == -1: + continue # junk images are just ignored + assert 0 <= pid <= 1501 # pid == 0 means background + assert 1 <= camid <= 20 + camid -= 1 # index starts from 0 + if relabel: + pid = pid2label[pid] + dataset.append((img_path, pid, camid)) + + return dataset diff --git a/src/eval_metrics.py b/src/eval_metrics.py new file mode 100644 index 0000000..9d68e59 --- /dev/null +++ b/src/eval_metrics.py @@ -0,0 +1,129 @@ +# Copyright (c) EEEM071, University of Surrey + +import numpy as np + + +def eval_vehicleid(distmat, q_pids, g_pids, q_camids, g_camids, max_rank): + """Evaluation with vehicleid metric + Key: gallery contains one images for each test vehicles and the other images in test + use as query + """ + num_q, num_g = distmat.shape + + if num_g < max_rank: + max_rank = num_g + print(f"Note: number of gallery samples is quite small, got {num_g}") + + indices = np.argsort(distmat, axis=1) + matches = (g_pids[indices] == q_pids[:, np.newaxis]).astype(np.int32) + + # compute cmc curve for each query + all_cmc = [] + all_AP = [] + num_valid_q = 0.0 # number of valid query + + for q_idx in range(num_q): + # get query pid and camid + # remove gallery samples that have the same pid and camid with query + """ + q_pid = q_pids[q_idx] + q_camid = q_camids[q_idx] + order = indices[q_idx] + remove = (g_pids[order] == q_pid) & (g_camids[order] == q_camid) # original remove + """ + remove = False # without camid imformation remove no images in gallery + keep = np.invert(remove) + # compute cmc curve + raw_cmc = matches[q_idx][ + keep + ] # binary vector, positions with value 1 are correct matches + if not np.any(raw_cmc): + # this condition is true when query identity does not appear in gallery + continue + + cmc = raw_cmc.cumsum() + cmc[cmc > 1] = 1 + + all_cmc.append(cmc[:max_rank]) + num_valid_q += 1.0 + + # compute average precision + # reference: https://en.wikipedia.org/wiki/Evaluation_measures_(information_retrieval)#Average_precision + num_rel = raw_cmc.sum() + tmp_cmc = raw_cmc.cumsum() + tmp_cmc = [x / (i + 1.0) for i, x in enumerate(tmp_cmc)] + tmp_cmc = np.asarray(tmp_cmc) * raw_cmc + AP = tmp_cmc.sum() / num_rel + all_AP.append(AP) + + assert num_valid_q > 0, "Error: all query identities do not appear in gallery" + + all_cmc = np.asarray(all_cmc).astype(np.float32) + all_cmc = all_cmc.sum(0) / num_valid_q + mAP = np.mean(all_AP) + + return all_cmc, mAP + + +def eval_veri(distmat, q_pids, g_pids, q_camids, g_camids, max_rank): + """Evaluation with veri metric + Key: for each query identity, its gallery images from the same camera view are discarded. + """ + num_q, num_g = distmat.shape + + if num_g < max_rank: + max_rank = num_g + print(f"Note: number of gallery samples is quite small, got {num_g}") + + indices = np.argsort(distmat, axis=1) + matches = (g_pids[indices] == q_pids[:, np.newaxis]).astype(np.int32) + + # compute cmc curve for each query + all_cmc = [] + all_AP = [] + num_valid_q = 0.0 # number of valid query + + for q_idx in range(num_q): + # get query pid and camid + q_pid = q_pids[q_idx] + q_camid = q_camids[q_idx] + + # remove gallery samples that have the same pid and camid with query + order = indices[q_idx] + remove = (g_pids[order] == q_pid) & (g_camids[order] == q_camid) + keep = np.invert(remove) + + # compute cmc curve + raw_cmc = matches[q_idx][ + keep + ] # binary vector, positions with value 1 are correct matches + if not np.any(raw_cmc): + # this condition is true when query identity does not appear in gallery + continue + + cmc = raw_cmc.cumsum() + cmc[cmc > 1] = 1 + + all_cmc.append(cmc[:max_rank]) + num_valid_q += 1.0 + + # compute average precision + # reference: https://en.wikipedia.org/wiki/Evaluation_measures_(information_retrieval)#Average_precision + num_rel = raw_cmc.sum() + tmp_cmc = raw_cmc.cumsum() + tmp_cmc = [x / (i + 1.0) for i, x in enumerate(tmp_cmc)] + tmp_cmc = np.asarray(tmp_cmc) * raw_cmc + AP = tmp_cmc.sum() / num_rel + all_AP.append(AP) + + assert num_valid_q > 0, "Error: all query identities do not appear in gallery" + + all_cmc = np.asarray(all_cmc).astype(np.float32) + all_cmc = all_cmc.sum(0) / num_valid_q + mAP = np.mean(all_AP) + + return all_cmc, mAP + + +def evaluate(distmat, q_pids, g_pids, q_camids, g_camids, max_rank=50): + return eval_veri(distmat, q_pids, g_pids, q_camids, g_camids, max_rank) diff --git a/src/losses/__init__.py b/src/losses/__init__.py new file mode 100644 index 0000000..957b28c --- /dev/null +++ b/src/losses/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) EEEM071, University of Surrey + +from .cross_entropy_loss import CrossEntropyLoss +from .hard_mine_triplet_loss import TripletLoss + + +def DeepSupervision(criterion, xs, y): + """ + Args: + - criterion: loss function + - xs: tuple of inputs + - y: ground truth + """ + loss = 0.0 + for x in xs: + loss += criterion(x, y) + loss /= len(xs) + return loss diff --git a/src/losses/cross_entropy_loss.py b/src/losses/cross_entropy_loss.py new file mode 100644 index 0000000..c558272 --- /dev/null +++ b/src/losses/cross_entropy_loss.py @@ -0,0 +1,43 @@ +# Copyright (c) EEEM071, University of Surrey + +import torch +import torch.nn as nn + + +class CrossEntropyLoss(nn.Module): + """Cross entropy loss with label smoothing regularizer. + + Reference: + Szegedy et al. Rethinking the Inception Architecture for Computer Vision. CVPR 2016. + + Equation: y = (1 - epsilon) * y + epsilon / K. + + Args: + - num_classes (int): number of classes + - epsilon (float): weight + - use_gpu (bool): whether to use gpu devices + - label_smooth (bool): whether to apply label smoothing, if False, epsilon = 0 + """ + + def __init__(self, num_classes, epsilon=0.1, use_gpu=True, label_smooth=True): + super().__init__() + self.num_classes = num_classes + self.epsilon = epsilon if label_smooth else 0 + self.use_gpu = use_gpu + self.logsoftmax = nn.LogSoftmax(dim=1) + + def forward(self, inputs, targets): + """ + Args: + - inputs: prediction matrix (before softmax) with shape (batch_size, num_classes) + - targets: ground truth labels with shape (num_classes) + """ + log_probs = self.logsoftmax(inputs) + targets = torch.zeros(log_probs.size()).scatter_( + 1, targets.unsqueeze(1).data.cpu(), 1 + ) + if self.use_gpu: + targets = targets.cuda() + targets = (1 - self.epsilon) * targets + self.epsilon / self.num_classes + loss = (-targets * log_probs).mean(0).sum() + return loss diff --git a/src/losses/hard_mine_triplet_loss.py b/src/losses/hard_mine_triplet_loss.py new file mode 100644 index 0000000..07f0126 --- /dev/null +++ b/src/losses/hard_mine_triplet_loss.py @@ -0,0 +1,49 @@ +# Copyright (c) EEEM071, University of Surrey + +import torch +import torch.nn as nn + + +class TripletLoss(nn.Module): + """Triplet loss with hard positive/negative mining. + + Reference: + Hermans et al. In Defense of the Triplet Loss for Person Re-Identification. arXiv:1703.07737. + Code imported from https://github.com/Cysu/open-reid/blob/master/reid/loss/triplet.py. + + Args: + - margin (float): margin for triplet. + """ + + def __init__(self, margin=0.3): + super().__init__() + self.margin = margin + self.ranking_loss = nn.MarginRankingLoss(margin=margin) + + def forward(self, inputs, targets): + """ + Args: + - inputs: feature matrix with shape (batch_size, feat_dim) + - targets: ground truth labels with shape (num_classes) + """ + n = inputs.size(0) + + # Compute pairwise distance, replace by the official when merged + dist = torch.pow(inputs, 2).sum(dim=1, keepdim=True).expand(n, n) + dist = dist + dist.t() + dist.addmm_(inputs, inputs.t(), beta=1, alpha=-2) + dist = dist.clamp(min=1e-12).sqrt() # for numerical stability + + # For each anchor, find the hardest positive and negative + mask = targets.expand(n, n).eq(targets.expand(n, n).t()) + dist_ap, dist_an = [], [] + for i in range(n): + dist_ap.append(dist[i][mask[i]].max().unsqueeze(0)) + dist_an.append(dist[i][mask[i] == 0].min().unsqueeze(0)) + dist_ap = torch.cat(dist_ap) + dist_an = torch.cat(dist_an) + + # Compute ranking hinge loss + y = torch.ones_like(dist_an) + loss = self.ranking_loss(dist_an, dist_ap, y) + return loss diff --git a/src/lr_schedulers.py b/src/lr_schedulers.py new file mode 100644 index 0000000..22388ad --- /dev/null +++ b/src/lr_schedulers.py @@ -0,0 +1,23 @@ +# Copyright (c) EEEM071, University of Surrey + +import torch + + +def init_lr_scheduler( + optimizer, + lr_scheduler="multi_step", # learning rate scheduler + stepsize=[20, 40], # step size to decay learning rate + gamma=0.1, # learning rate decay +): + if lr_scheduler == "single_step": + return torch.optim.lr_scheduler.StepLR( + optimizer, step_size=stepsize[0], gamma=gamma + ) + + elif lr_scheduler == "multi_step": + return torch.optim.lr_scheduler.MultiStepLR( + optimizer, milestones=stepsize, gamma=gamma + ) + + else: + raise ValueError(f"Unsupported lr_scheduler: {lr_scheduler}") diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..455e704 --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) EEEM071, University of Surrey + +from .resnet import resnet50, resnet50_fc512 + + +__model_factory = { + # image classification models + "resnet50": resnet50, + "resnet50_fc512": resnet50_fc512, +} + + +def get_names(): + return list(__model_factory.keys()) + + +def init_model(name, *args, **kwargs): + if name not in list(__model_factory.keys()): + raise KeyError(f"Unknown model: {name}") + return __model_factory[name](*args, **kwargs) diff --git a/src/models/resnet.py b/src/models/resnet.py new file mode 100644 index 0000000..2606ebc --- /dev/null +++ b/src/models/resnet.py @@ -0,0 +1,298 @@ +# Copyright (c) EEEM071, University of Surrey + +import torch +import torch.utils.model_zoo as model_zoo +import torchvision +from torch import nn +from torch.nn import functional as F + +__all__ = ["resnet50", "resnet50_fc512"] + +model_urls = { + "resnet18": "https://download.pytorch.org/models/resnet18-5c106cde.pth", + "resnet34": "https://download.pytorch.org/models/resnet34-333f7ec4.pth", + "resnet50": "https://download.pytorch.org/models/resnet50-19c8e357.pth", + "resnet101": "https://download.pytorch.org/models/resnet101-5d3b4d8f.pth", + "resnet152": "https://download.pytorch.org/models/resnet152-b121ed2d.pth", +} + + +def conv3x3(in_planes, out_planes, stride=1): + """3x3 convolution with padding""" + return nn.Conv2d( + in_planes, out_planes, kernel_size=3, stride=stride, padding=1, bias=False + ) + + +class BasicBlock(nn.Module): + expansion = 1 + + def __init__(self, inplanes, planes, stride=1, downsample=None): + super().__init__() + self.conv1 = conv3x3(inplanes, planes, stride) + self.bn1 = nn.BatchNorm2d(planes) + self.relu = nn.ReLU(inplace=True) + self.conv2 = conv3x3(planes, planes) + self.bn2 = nn.BatchNorm2d(planes) + self.downsample = downsample + self.stride = stride + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.bn1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.bn2(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + + +class Bottleneck(nn.Module): + expansion = 4 + + def __init__(self, inplanes, planes, stride=1, downsample=None): + super().__init__() + self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=1, bias=False) + self.bn1 = nn.BatchNorm2d(planes) + self.conv2 = nn.Conv2d( + planes, planes, kernel_size=3, stride=stride, padding=1, bias=False + ) + self.bn2 = nn.BatchNorm2d(planes) + self.conv3 = nn.Conv2d( + planes, planes * self.expansion, kernel_size=1, bias=False + ) + self.bn3 = nn.BatchNorm2d(planes * self.expansion) + self.relu = nn.ReLU(inplace=True) + self.downsample = downsample + self.stride = stride + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.bn1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.bn2(out) + out = self.relu(out) + + out = self.conv3(out) + out = self.bn3(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + + +class ResNet(nn.Module): + """ + Residual network + + Reference: + He et al. Deep Residual Learning for Image Recognition. CVPR 2016. + """ + + def __init__( + self, + num_classes, + loss, + block, + layers, + last_stride=2, + fc_dims=None, + dropout_p=None, + **kwargs, + ): + self.inplanes = 64 + super().__init__() + self.loss = loss + self.feature_dim = 512 * block.expansion + + # backbone network + self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) + self.bn1 = nn.BatchNorm2d(64) + self.relu = nn.ReLU(inplace=True) + self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) + self.layer1 = self._make_layer(block, 64, layers[0]) + self.layer2 = self._make_layer(block, 128, layers[1], stride=2) + self.layer3 = self._make_layer(block, 256, layers[2], stride=2) + self.layer4 = self._make_layer(block, 512, layers[3], stride=last_stride) + + self.global_avgpool = nn.AdaptiveAvgPool2d(1) + self.fc = self._construct_fc_layer(fc_dims, 512 * block.expansion, dropout_p) + self.classifier = nn.Linear(self.feature_dim, num_classes) + + self._init_params() + + def _make_layer(self, block, planes, blocks, stride=1): + downsample = None + if stride != 1 or self.inplanes != planes * block.expansion: + downsample = nn.Sequential( + nn.Conv2d( + self.inplanes, + planes * block.expansion, + kernel_size=1, + stride=stride, + bias=False, + ), + nn.BatchNorm2d(planes * block.expansion), + ) + + layers = [] + layers.append(block(self.inplanes, planes, stride, downsample)) + self.inplanes = planes * block.expansion + for i in range(1, blocks): + layers.append(block(self.inplanes, planes)) + + return nn.Sequential(*layers) + + def _construct_fc_layer(self, fc_dims, input_dim, dropout_p=None): + """ + Construct fully connected layer + - fc_dims (list or tuple): dimensions of fc layers, if None, + no fc layers are constructed + - input_dim (int): input dimension + - dropout_p (float): dropout probability, if None, dropout is unused + """ + if fc_dims is None: + self.feature_dim = input_dim + return None + + assert isinstance( + fc_dims, (list, tuple) + ), "fc_dims must be either list or tuple, but got {}".format(type(fc_dims)) + + layers = [] + for dim in fc_dims: + layers.append(nn.Linear(input_dim, dim)) + layers.append(nn.BatchNorm1d(dim)) + layers.append(nn.ReLU(inplace=True)) + if dropout_p is not None: + layers.append(nn.Dropout(p=dropout_p)) + input_dim = dim + + self.feature_dim = fc_dims[-1] + + return nn.Sequential(*layers) + + def _init_params(self): + for m in self.modules(): + if isinstance(m, nn.Conv2d): + nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") + if m.bias is not None: + nn.init.constant_(m.bias, 0) + elif isinstance(m, nn.BatchNorm2d): + nn.init.constant_(m.weight, 1) + nn.init.constant_(m.bias, 0) + elif isinstance(m, nn.BatchNorm1d): + nn.init.constant_(m.weight, 1) + nn.init.constant_(m.bias, 0) + elif isinstance(m, nn.Linear): + nn.init.normal_(m.weight, 0, 0.01) + if m.bias is not None: + nn.init.constant_(m.bias, 0) + + def featuremaps(self, x): + x = self.conv1(x) + x = self.bn1(x) + x = self.relu(x) + x = self.maxpool(x) + x = self.layer1(x) + x = self.layer2(x) + x = self.layer3(x) + x = self.layer4(x) + return x + + def forward(self, x): + f = self.featuremaps(x) + v = self.global_avgpool(f) + v = v.view(v.size(0), -1) + + if self.fc is not None: + v = self.fc(v) + + if not self.training: + return v + + y = self.classifier(v) + + if self.loss == {"xent"}: + return y + elif self.loss == {"xent", "htri"}: + return y, v + else: + raise KeyError(f"Unsupported loss: {self.loss}") + + +def init_pretrained_weights(model, model_url): + """ + Initialize model with pretrained weights. + Layers that don't match with pretrained layers in name or size are kept unchanged. + """ + pretrain_dict = model_zoo.load_url(model_url) + model_dict = model.state_dict() + pretrain_dict = { + k: v + for k, v in pretrain_dict.items() + if k in model_dict and model_dict[k].size() == v.size() + } + model_dict.update(pretrain_dict) + model.load_state_dict(model_dict) + print(f"Initialized model with pretrained weights from {model_url}") + + +""" +Residual network configurations: +-- +resnet18: block=BasicBlock, layers=[2, 2, 2, 2] +resnet34: block=BasicBlock, layers=[3, 4, 6, 3] +resnet50: block=Bottleneck, layers=[3, 4, 6, 3] +resnet101: block=Bottleneck, layers=[3, 4, 23, 3] +resnet152: block=Bottleneck, layers=[3, 8, 36, 3] +""" + + +def resnet50(num_classes, loss={"xent"}, pretrained=True, **kwargs): + model = ResNet( + num_classes=num_classes, + loss=loss, + block=Bottleneck, + layers=[3, 4, 6, 3], + last_stride=2, + fc_dims=None, + dropout_p=None, + **kwargs, + ) + if pretrained: + init_pretrained_weights(model, model_urls["resnet50"]) + return model + + +def resnet50_fc512(num_classes, loss={"xent"}, pretrained=True, **kwargs): + model = ResNet( + num_classes=num_classes, + loss=loss, + block=Bottleneck, + layers=[3, 4, 6, 3], + last_stride=1, + fc_dims=[512], + dropout_p=None, + **kwargs, + ) + if pretrained: + init_pretrained_weights(model, model_urls["resnet50"]) + return model diff --git a/src/optimizers.py b/src/optimizers.py new file mode 100644 index 0000000..9d94b36 --- /dev/null +++ b/src/optimizers.py @@ -0,0 +1,85 @@ +# Copyright (c) EEEM071, University of Surrey + +import torch +import torch.nn as nn + + +def init_optimizer( + model, + optim="adam", # optimizer choices + lr=0.003, # learning rate + weight_decay=5e-4, # weight decay + momentum=0.9, # momentum factor for sgd and rmsprop + sgd_dampening=0, # sgd's dampening for momentum + sgd_nesterov=False, # whether to enable sgd's Nesterov momentum + rmsprop_alpha=0.99, # rmsprop's smoothing constant + adam_beta1=0.9, # exponential decay rate for adam's first moment + adam_beta2=0.999, # # exponential decay rate for adam's second moment + staged_lr=False, # different lr for different layers + new_layers=None, # new layers use the default lr, while other layers's lr is scaled by base_lr_mult + base_lr_mult=0.1, # learning rate multiplier for base layers +): + if staged_lr: + assert new_layers is not None + base_params = [] + base_layers = [] + new_params = [] + if isinstance(model, nn.DataParallel): + model = model.module + for name, module in model.named_children(): + if name in new_layers: + new_params += [p for p in module.parameters()] + else: + base_params += [p for p in module.parameters()] + base_layers.append(name) + param_groups = [ + {"params": base_params, "lr": lr * base_lr_mult}, + {"params": new_params}, + ] + print("Use staged learning rate") + print( + "* Base layers (initial lr = {}): {}".format(lr * base_lr_mult, base_layers) + ) + print(f"* New layers (initial lr = {lr}): {new_layers}") + else: + param_groups = model.parameters() + + # Construct optimizer + if optim == "adam": + return torch.optim.Adam( + param_groups, + lr=lr, + weight_decay=weight_decay, + betas=(adam_beta1, adam_beta2), + ) + + elif optim == "amsgrad": + return torch.optim.Adam( + param_groups, + lr=lr, + weight_decay=weight_decay, + betas=(adam_beta1, adam_beta2), + amsgrad=True, + ) + + elif optim == "sgd": + return torch.optim.SGD( + param_groups, + lr=lr, + momentum=momentum, + weight_decay=weight_decay, + dampening=sgd_dampening, + nesterov=sgd_nesterov, + ) + + elif optim == "rmsprop": + return torch.optim.RMSprop( + param_groups, + lr=lr, + momentum=momentum, + weight_decay=weight_decay, + alpha=rmsprop_alpha, + ) + + else: + raise ValueError(f"Unsupported optimizer: {optim}") diff --git a/src/samplers.py b/src/samplers.py new file mode 100644 index 0000000..b410065 --- /dev/null +++ b/src/samplers.py @@ -0,0 +1,89 @@ +# Copyright (c) EEEM071, University of Surrey + +import copy +import random +from collections import defaultdict + +import numpy as np +from torch.utils.data.sampler import Sampler, RandomSampler + + +class RandomIdentitySampler(Sampler): + """ + Randomly sample N identities, then for each identity, + randomly sample K instances, therefore batch size is N*K. + Args: + - data_source (list): list of (img_path, pid, camid). + - num_instances (int): number of instances per identity in a batch. + - batch_size (int): number of examples in a batch. + """ + + def __init__(self, data_source, batch_size, num_instances): + self.data_source = data_source + self.batch_size = batch_size + self.num_instances = num_instances + self.num_pids_per_batch = self.batch_size // self.num_instances + self.index_dic = defaultdict(list) + for index, (_, pid, _) in enumerate(self.data_source): + self.index_dic[pid].append(index) + self.pids = list(self.index_dic.keys()) + + # estimate number of examples in an epoch + self.length = 0 + for pid in self.pids: + idxs = self.index_dic[pid] + num = len(idxs) + if num < self.num_instances: + num = self.num_instances + self.length += num - num % self.num_instances + + def __iter__(self): + batch_idxs_dict = defaultdict(list) + + for pid in self.pids: + idxs = copy.deepcopy(self.index_dic[pid]) + if len(idxs) < self.num_instances: + idxs = np.random.choice(idxs, size=self.num_instances, replace=True) + random.shuffle(idxs) + batch_idxs = [] + for idx in idxs: + batch_idxs.append(idx) + if len(batch_idxs) == self.num_instances: + batch_idxs_dict[pid].append(batch_idxs) + batch_idxs = [] + + avai_pids = copy.deepcopy(self.pids) + final_idxs = [] + + while len(avai_pids) >= self.num_pids_per_batch: + selected_pids = random.sample(avai_pids, self.num_pids_per_batch) + for pid in selected_pids: + batch_idxs = batch_idxs_dict[pid].pop(0) + final_idxs.extend(batch_idxs) + if len(batch_idxs_dict[pid]) == 0: + avai_pids.remove(pid) + + return iter(final_idxs) + + def __len__(self): + return self.length + + +def build_train_sampler( + data_source, train_sampler, train_batch_size, num_instances, **kwargs +): + """Build sampler for training + Args: + - data_source (list): list of (img_path, pid, camid). + - train_sampler (str): sampler name (default: RandomSampler). + - train_batch_size (int): batch size during training. + - num_instances (int): number of instances per identity in a batch (for RandomIdentitySampler). + """ + + if train_sampler == "RandomIdentitySampler": + sampler = RandomIdentitySampler(data_source, train_batch_size, num_instances) + + else: + sampler = RandomSampler(data_source) + + return sampler diff --git a/src/transforms.py b/src/transforms.py new file mode 100644 index 0000000..4356146 --- /dev/null +++ b/src/transforms.py @@ -0,0 +1,165 @@ +# Copyright (c) EEEM071, University of Surrey + +import math +import random + +import torch +import torchvision.transforms as T +from PIL import Image + + +class Random2DTranslation: + """ + With a probability, first increase image size to (1 + 1/8), and then perform random crop. + Args: + - height (int): target image height. + - width (int): target image width. + - p (float): probability of performing this transformation. Default: 0.5. + """ + + def __init__(self, height, width, p=0.5, interpolation=Image.BILINEAR): + self.height = height + self.width = width + self.p = p + self.interpolation = interpolation + + def __call__(self, img): + """ + Args: + - img (PIL Image): Image to be cropped. + """ + if random.uniform(0, 1) > self.p: + return img.resize((self.width, self.height), self.interpolation) + + new_width, new_height = int(round(self.width * 1.125)), int( + round(self.height * 1.125) + ) + resized_img = img.resize((new_width, new_height), self.interpolation) + x_maxrange = new_width - self.width + y_maxrange = new_height - self.height + x1 = int(round(random.uniform(0, x_maxrange))) + y1 = int(round(random.uniform(0, y_maxrange))) + croped_img = resized_img.crop((x1, y1, x1 + self.width, y1 + self.height)) + return croped_img + + +class RandomErasing: + """ + Class that performs Random Erasing in Random Erasing Data Augmentation by Zhong et al. + ------------------------------------------------------------------------------------- + probability: The probability that the operation will be performed. + sl: min erasing area + sh: max erasing area + r1: min aspect ratio + mean: erasing value + ------------------------------------------------------------------------------------- + Origin: https://github.com/zhunzhong07/Random-Erasing + """ + + def __init__( + self, probability=0.5, sl=0.02, sh=0.4, r1=0.3, mean=[0.4914, 0.4822, 0.4465] + ): + self.probability = probability + self.mean = mean + self.sl = sl + self.sh = sh + self.r1 = r1 + + def __call__(self, img): + + if random.uniform(0, 1) > self.probability: + return img + + for attempt in range(100): + area = img.size()[1] * img.size()[2] + + target_area = random.uniform(self.sl, self.sh) * area + aspect_ratio = random.uniform(self.r1, 1 / self.r1) + + h = int(round(math.sqrt(target_area * aspect_ratio))) + w = int(round(math.sqrt(target_area / aspect_ratio))) + + if w < img.size()[2] and h < img.size()[1]: + x1 = random.randint(0, img.size()[1] - h) + y1 = random.randint(0, img.size()[2] - w) + if img.size()[0] == 3: + img[0, x1 : x1 + h, y1 : y1 + w] = self.mean[0] + img[1, x1 : x1 + h, y1 : y1 + w] = self.mean[1] + img[2, x1 : x1 + h, y1 : y1 + w] = self.mean[2] + else: + img[0, x1 : x1 + h, y1 : y1 + w] = self.mean[0] + return img + + return img + + +class ColorAugmentation: + """ + Randomly alter the intensities of RGB channels + Reference: + Krizhevsky et al. ImageNet Classification with Deep ConvolutionalNeural Networks. NIPS 2012. + """ + + def __init__(self, p=0.5): + self.p = p + self.eig_vec = torch.Tensor( + [ + [0.4009, 0.7192, -0.5675], + [-0.8140, -0.0045, -0.5808], + [0.4203, -0.6948, -0.5836], + ] + ) + self.eig_val = torch.Tensor([[0.2175, 0.0188, 0.0045]]) + + def _check_input(self, tensor): + assert tensor.dim() == 3 and tensor.size(0) == 3 + + def __call__(self, tensor): + if random.uniform(0, 1) > self.p: + return tensor + alpha = torch.normal(mean=torch.zeros_like(self.eig_val)) * 0.1 + quatity = torch.mm(self.eig_val * alpha, self.eig_vec) + tensor = tensor + quatity.view(3, 1, 1) + return tensor + + +def build_transforms( + height, + width, + random_erase=False, # use random erasing for data augmentation + color_jitter=False, # randomly change the brightness, contrast and saturation + color_aug=False, # randomly alter the intensities of RGB channels + **kwargs +): + # use imagenet mean and std as default + # TODO: compute dataset-specific mean and std + imagenet_mean = [0.485, 0.456, 0.406] + imagenet_std = [0.229, 0.224, 0.225] + normalize = T.Normalize(mean=imagenet_mean, std=imagenet_std) + + # build train transformations + transform_train = [] + transform_train += [Random2DTranslation(height, width)] + transform_train += [T.RandomHorizontalFlip()] + if color_jitter: + transform_train += [ + T.ColorJitter(brightness=0.2, contrast=0.15, saturation=0, hue=0) + ] + transform_train += [T.ToTensor()] + if color_aug: + transform_train += [ColorAugmentation()] + transform_train += [normalize] + if random_erase: + transform_train += [RandomErasing()] + transform_train = T.Compose(transform_train) + + # build test transformations + transform_test = T.Compose( + [ + T.Resize((height, width)), + T.ToTensor(), + normalize, + ] + ) + + return transform_train, transform_test diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..418256f --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1 @@ +# Copyright (c) EEEM071, University of Surrey diff --git a/src/utils/avgmeter.py b/src/utils/avgmeter.py new file mode 100644 index 0000000..292be49 --- /dev/null +++ b/src/utils/avgmeter.py @@ -0,0 +1,23 @@ +# Copyright (c) EEEM071, University of Surrey + + +class AverageMeter: + """Computes and stores the average and current value. + + Code imported from https://github.com/pytorch/examples/blob/master/imagenet/main.py#L247-L262 + """ + + def __init__(self): + self.reset() + + def reset(self): + self.val = 0 + self.avg = 0 + self.sum = 0 + self.count = 0 + + def update(self, val, n=1): + self.val = val + self.sum += val * n + self.count += n + self.avg = self.sum / self.count diff --git a/src/utils/generaltools.py b/src/utils/generaltools.py new file mode 100644 index 0000000..c37cfa1 --- /dev/null +++ b/src/utils/generaltools.py @@ -0,0 +1,13 @@ +# Copyright (c) EEEM071, University of Surrey + +import random + +import numpy as np +import torch + + +def set_random_seed(seed): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.cuda.manual_seed_all(seed) diff --git a/src/utils/iotools.py b/src/utils/iotools.py new file mode 100644 index 0000000..8d79cce --- /dev/null +++ b/src/utils/iotools.py @@ -0,0 +1,35 @@ +# Copyright (c) EEEM071, University of Surrey + +import errno +import json +import os +import os.path as osp +import warnings + + +def mkdir_if_missing(directory): + if not osp.exists(directory): + try: + os.makedirs(directory) + except OSError as e: + if e.errno != errno.EEXIST: + raise + + +def check_isfile(path): + isfile = osp.isfile(path) + if not isfile: + warnings.warn(f'No file found at "{path}"') + return isfile + + +def read_json(fpath): + with open(fpath) as f: + obj = json.load(f) + return obj + + +def write_json(obj, fpath): + mkdir_if_missing(osp.dirname(fpath)) + with open(fpath, "w") as f: + json.dump(obj, f, indent=4, separators=(",", ": ")) diff --git a/src/utils/loggers.py b/src/utils/loggers.py new file mode 100644 index 0000000..2e17d42 --- /dev/null +++ b/src/utils/loggers.py @@ -0,0 +1,76 @@ +# Copyright (c) EEEM071, University of Surrey + +import os +import os.path as osp +import sys + +from .iotools import mkdir_if_missing + + +class Logger: + """ + Write console output to external text file. + Code imported from https://github.com/Cysu/open-reid/blob/master/reid/utils/logging.py. + """ + + def __init__(self, fpath=None): + self.console = sys.stdout + self.file = None + if fpath is not None: + mkdir_if_missing(osp.dirname(fpath)) + self.file = open(fpath, "w") + + def __del__(self): + self.close() + + def __enter__(self): + pass + + def __exit__(self, *args): + self.close() + + def write(self, msg): + self.console.write(msg) + if self.file is not None: + self.file.write(msg) + + def flush(self): + self.console.flush() + if self.file is not None: + self.file.flush() + os.fsync(self.file.fileno()) + + def close(self): + self.console.close() + if self.file is not None: + self.file.close() + + +class RankLogger: + """ + RankLogger records the rank1 matching accuracy obtained for each + test dataset at specified evaluation steps and provides a function + to show the summarized results, which are convenient for analysis. + Args: + - source_names (list): list of strings (names) of source datasets. + - target_names (list): list of strings (names) of target datasets. + """ + + def __init__(self, source_names, target_names): + self.source_names = source_names + self.target_names = target_names + self.logger = {name: {"epoch": [], "rank1": []} for name in self.target_names} + + def write(self, name, epoch, rank1): + self.logger[name]["epoch"].append(epoch) + self.logger[name]["rank1"].append(rank1) + + def show_summary(self): + print("=> Show performance summary") + for name in self.target_names: + from_where = "source" if name in self.source_names else "target" + print(f"{name} ({from_where})") + for epoch, rank1 in zip( + self.logger[name]["epoch"], self.logger[name]["rank1"] + ): + print(f"- epoch {epoch}\t rank1 {rank1:.1%}") diff --git a/src/utils/mean_and_std.py b/src/utils/mean_and_std.py new file mode 100644 index 0000000..3e81ebb --- /dev/null +++ b/src/utils/mean_and_std.py @@ -0,0 +1,29 @@ +# Copyright (c) EEEM071, University of Surrey + +import torch + + +def get_mean_and_std(dataloader, dataset): + # Compute the mean and std value of dataset. + mean = torch.zeros(3) + std = torch.zeros(3) + print("==> Computing mean and std..") + for inputs, _, _ in dataloader: + for i in range(3): + mean[i] += inputs[:, i, :, :].mean() + std[i] += inputs[:, i, :, :].std() + mean.div_(len(dataset)) + std.div_(len(dataset)) + return mean, std + + +def calculate_mean_and_std(dataset_loader, dataset_size): + mean = torch.zeros(3) + std = torch.zeros(3) + for data in dataset_loader: + now_batch_size, c, h, w = data[0].shape + mean += torch.sum(torch.mean(torch.mean(data[0], dim=3), dim=2), dim=0) + std += torch.sum( + torch.std(data[0].view(now_batch_size, c, h * w), dim=2), dim=0 + ) + return mean / dataset_size, std / dataset_size diff --git a/src/utils/torchtools.py b/src/utils/torchtools.py new file mode 100644 index 0000000..b1bbd34 --- /dev/null +++ b/src/utils/torchtools.py @@ -0,0 +1,194 @@ +# Copyright (c) EEEM071, University of Surrey + +import os.path as osp +import shutil +import warnings +from collections import OrderedDict + +import torch +import torch.nn as nn + +from .iotools import mkdir_if_missing + + +def save_checkpoint(state, save_dir, is_best=False, remove_module_from_keys=False): + mkdir_if_missing(save_dir) + if remove_module_from_keys: + # remove 'module.' in state_dict's keys + state_dict = state["state_dict"] + new_state_dict = OrderedDict() + for k, v in state_dict.items(): + if k.startswith("module."): + k = k[7:] + new_state_dict[k] = v + state["state_dict"] = new_state_dict + # save + epoch = state["epoch"] + fpath = osp.join(save_dir, "model.pth.tar-" + str(epoch)) + torch.save(state, fpath) + print(f'Checkpoint saved to "{fpath}"') + if is_best: + shutil.copy(fpath, osp.join(osp.dirname(fpath), "best_model.pth.tar")) + + +def resume_from_checkpoint(ckpt_path, model, optimizer=None): + print(f'Loading checkpoint from "{ckpt_path}"') + ckpt = torch.load(ckpt_path) + model.load_state_dict(ckpt["state_dict"]) + print("Loaded model weights") + if optimizer is not None: + optimizer.load_state_dict(ckpt["optimizer"]) + print("Loaded optimizer") + start_epoch = ckpt["epoch"] + print( + "** previous epoch = {}\t previous rank1 = {:.1%}".format( + start_epoch, ckpt["rank1"] + ) + ) + return start_epoch + + +def adjust_learning_rate( + optimizer, + base_lr, + epoch, + stepsize=20, + gamma=0.1, + linear_decay=False, + final_lr=0, + max_epoch=100, +): + if linear_decay: + # linearly decay learning rate from base_lr to final_lr + frac_done = epoch / max_epoch + lr = frac_done * final_lr + (1.0 - frac_done) * base_lr + else: + # decay learning rate by gamma for every stepsize + lr = base_lr * (gamma ** (epoch // stepsize)) + + for param_group in optimizer.param_groups: + param_group["lr"] = lr + + +def set_bn_to_eval(m): + # 1. no update for running mean and var + # 2. scale and shift parameters are still trainable + classname = m.__class__.__name__ + if classname.find("BatchNorm") != -1: + m.eval() + + +def open_all_layers(model): + """ + Open all layers in model for training. + Args: + - model (nn.Module): neural net model. + """ + model.train() + for p in model.parameters(): + p.requires_grad = True + + +def open_specified_layers(model, open_layers): + """ + Open specified layers in model for training while keeping + other layers frozen. + Args: + - model (nn.Module): neural net model. + - open_layers (list): list of layer names. + """ + if isinstance(model, nn.DataParallel): + model = model.module + + for layer in open_layers: + assert hasattr( + model, layer + ), '"{}" is not an attribute of the model, please provide the correct name'.format( + layer + ) + + for name, module in model.named_children(): + if name in open_layers: + module.train() + for p in module.parameters(): + p.requires_grad = True + else: + module.eval() + for p in module.parameters(): + p.requires_grad = False + + +def count_num_param(model): + num_param = sum(p.numel() for p in model.parameters()) / 1e06 + + if isinstance(model, nn.DataParallel): + model = model.module + + if hasattr(model, "classifier") and isinstance(model.classifier, nn.Module): + # we ignore the classifier because it is unused at test time + num_param -= sum(p.numel() for p in model.classifier.parameters()) / 1e06 + return num_param + + +def accuracy(output, target, topk=(1,)): + """Computes the accuracy over the k top predictions for the specified values of k""" + with torch.no_grad(): + maxk = max(topk) + batch_size = target.size(0) + + if isinstance(output, (tuple, list)): + output = output[0] + + _, pred = output.topk(maxk, 1, True, True) + pred = pred.t() + correct = pred.eq(target.view(1, -1).expand_as(pred)) + + res = [] + for k in topk: + correct_k = correct[:k].view(-1).float().sum(0, keepdim=True) + acc = correct_k.mul_(100.0 / batch_size) + res.append(acc.item()) + return res + + +def load_pretrained_weights(model, weight_path): + """Load pretrianed weights to model + Incompatible layers (unmatched in name or size) will be ignored + Args: + - model (nn.Module): network model, which must not be nn.DataParallel + - weight_path (str): path to pretrained weights + """ + checkpoint = torch.load(weight_path) + if "state_dict" in checkpoint: + state_dict = checkpoint["state_dict"] + else: + state_dict = checkpoint + model_dict = model.state_dict() + new_state_dict = OrderedDict() + matched_layers, discarded_layers = [], [] + for k, v in state_dict.items(): + # If the pretrained state_dict was saved as nn.DataParallel, + # keys would contain "module.", which should be ignored. + if k.startswith("module."): + k = k[7:] + if k in model_dict and model_dict[k].size() == v.size(): + new_state_dict[k] = v + matched_layers.append(k) + else: + discarded_layers.append(k) + model_dict.update(new_state_dict) + model.load_state_dict(model_dict) + if len(matched_layers) == 0: + warnings.warn( + 'The pretrained weights "{}" cannot be loaded, please check the key names manually (** ignored and continue **)'.format( + weight_path + ) + ) + else: + print(f'Successfully loaded pretrained weights from "{weight_path}"') + if len(discarded_layers) > 0: + print( + "** The following layers are discarded due to unmatched keys or layer size: {}".format( + discarded_layers + ) + ) diff --git a/src/utils/visualtools.py b/src/utils/visualtools.py new file mode 100644 index 0000000..515cca5 --- /dev/null +++ b/src/utils/visualtools.py @@ -0,0 +1,72 @@ +# Copyright (c) EEEM071, University of Surrey + +import os.path as osp +import shutil + +import numpy as np + +from .iotools import mkdir_if_missing + + +def visualize_ranked_results(distmat, dataset, save_dir="log/ranked_results", topk=20): + """ + Visualize ranked results + Args: + - distmat: distance matrix of shape (num_query, num_gallery). + - dataset: a 2-tuple containing (query, gallery), each contains a list of (img_path, pid, camid); + for imgreid, img_path is a string, while for vidreid, img_path is a tuple containing + a sequence of strings. + - save_dir: directory to save output images. + - topk: int, denoting top-k images in the rank list to be visualized. + """ + num_q, num_g = distmat.shape + + print(f"Visualizing top-{topk} ranks") + print(f"# query: {num_q}\n# gallery {num_g}") + print(f'Saving images to "{save_dir}"') + + query, gallery = dataset + assert num_q == len(query) + assert num_g == len(gallery) + + indices = np.argsort(distmat, axis=1) + mkdir_if_missing(save_dir) + + def _cp_img_to(src, dst, rank, prefix): + """ + - src: image path or tuple (for vidreid) + - dst: target directory + - rank: int, denoting ranked position, starting from 1 + - prefix: string + """ + if isinstance(src, tuple) or isinstance(src, list): + dst = osp.join(dst, prefix + "_top" + str(rank).zfill(3)) + mkdir_if_missing(dst) + for img_path in src: + shutil.copy(img_path, dst) + else: + dst = osp.join( + dst, prefix + "_top" + str(rank).zfill(3) + "_name_" + osp.basename(src) + ) + shutil.copy(src, dst) + + for q_idx in range(num_q): + qimg_path, qpid, qcamid = query[q_idx] + if isinstance(qimg_path, tuple) or isinstance(qimg_path, list): + qdir = osp.join(save_dir, osp.basename(qimg_path[0])) + else: + qdir = osp.join(save_dir, osp.basename(qimg_path)) + mkdir_if_missing(qdir) + _cp_img_to(qimg_path, qdir, rank=0, prefix="query") + + rank_idx = 1 + for g_idx in indices[q_idx, :]: + gimg_path, gpid, gcamid = gallery[g_idx] + invalid = (qpid == gpid) & (qcamid == gcamid) + if not invalid: + _cp_img_to(gimg_path, qdir, rank=rank_idx, prefix="gallery") + rank_idx += 1 + if rank_idx > topk: + break + + print("Done") diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..1180141 --- /dev/null +++ b/test.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +python main.py \ +-s cub200 \ +-t cub200 \ +-a resnet50 \ +--height 256 \ +--width 256 \ +--test-batch-size 100 \ +--evaluate \ +--save-dir logs/eval-cub200 diff --git a/train.sh b/train.sh new file mode 100644 index 0000000..0e3fb1e --- /dev/null +++ b/train.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +python main.py \ +-s cub200 \ +-t cub200 \ +-a resnet50 \ +--height 256 \ +--width 256 \ +--optim amsgrad \ +--lr 0.0003 \ +--max-epoch 60 \ +--stepsize 20 40 \ +--train-batch-size 64 \ +--test-batch-size 100 \ +--save-dir logs/resnet50-cub200