From 57dc12b305babee3e2150cdc82b36ea6dcae6e9d Mon Sep 17 00:00:00 2001 From: vd <> Date: Fri, 2 Sep 2022 03:48:26 +0300 Subject: [PATCH] First commit --- classifier_config.json | 6 + download-dataset | 4 + embedding_config.json | 15 +++ embedding_loss.pdf | Bin 0 -> 13139 bytes requirements.txt | 3 + train-classifier.py | 163 +++++++++++++++++++++++++ train-embedding.py | 265 +++++++++++++++++++++++++++++++++++++++++ visualized_simple.pdf | Bin 0 -> 23115 bytes 8 files changed, 456 insertions(+) create mode 100644 classifier_config.json create mode 100755 download-dataset create mode 100644 embedding_config.json create mode 100644 embedding_loss.pdf create mode 100644 requirements.txt create mode 100644 train-classifier.py create mode 100644 train-embedding.py create mode 100644 visualized_simple.pdf diff --git a/classifier_config.json b/classifier_config.json new file mode 100644 index 00000000..4d8c8652 --- /dev/null +++ b/classifier_config.json @@ -0,0 +1,6 @@ +{ + "n_neighbors": 10, + "classes" : ["black_red", "green_orange", "yellow_grey"], + "path_to_dataset": "./triplet_dataset", + "embedding_model": "./model_embedding.pt" +} diff --git a/download-dataset b/download-dataset new file mode 100755 index 00000000..73b17505 --- /dev/null +++ b/download-dataset @@ -0,0 +1,4 @@ +#!/bin/bash +gdown 1rP7GHDqx6BKTGTh9I6ecEmRgn5-HG1N0 +unzip -q "triplet_dataset.zip" +rm ./"triplet_dataset.zip" diff --git a/embedding_config.json b/embedding_config.json new file mode 100644 index 00000000..7d75d770 --- /dev/null +++ b/embedding_config.json @@ -0,0 +1,15 @@ +{ + "epochs": 4, + "embedding_dims": 128, + "batch_size": 32, + "classes" : ["black_red", "green_orange", "yellow_grey"], + "lr": 0.001, + "triplet-loss-margin": 1.0, + "triplet-loss-p": 2, + "train_size": 0.9, + "augmentations": true, + "env": "", + "path_to_dataset": "./triplet_dataset", + "visualize": true, + "pca_components": 16 +} diff --git a/embedding_loss.pdf b/embedding_loss.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5518c41ba943c46283f316e71d7bd89407f4f81d GIT binary patch literal 13139 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 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..128c5b39 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +torch +torchvision +sklearn diff --git a/train-classifier.py b/train-classifier.py new file mode 100644 index 00000000..baa5286c --- /dev/null +++ b/train-classifier.py @@ -0,0 +1,163 @@ +import torch +import random +import numpy as np +import pandas as pd +import torch.nn as nn +import torch.optim as optim +from tqdm.notebook import tqdm +import matplotlib.pyplot as plt +from pathlib import Path +from torch.utils.data import Dataset, DataLoader +from torchvision.io import read_image +from torchvision import transforms +from sklearn.neighbors import KNeighborsClassifier as kNN +import pickle +import json + +with open('classifier_config.json') as config_file: + config = json.load(config_file) + +path_to_dataset = Path(config["path_to_dataset"]) + +if len(config["classes"]) == 0: + classes = sorted([x.name for x in path_to_dataset.iterdir() if x.is_dir()]) +else: + classes = config["classes"] + +class SquarePad: + def __call__(self, image): + _, w, h = image.size() + max_wh = max(w, h) + hp = int((max_wh - w) / 2) + vp = int((max_wh - h) / 2) + padding = (vp, hp, vp, hp) + return transforms.functional.pad(image, padding, 0, 'constant') + +class Normalize01: + def __call__(self, image): + image -= image.min() + image /= image.max() + return image + +class CustomDataset(Dataset): + def __init__(self, path, classes = None, augmentations=None, target_transform=None, size = (64, 64)): + self.path = path # path to directories + + self.transform_aug = augmentations + self.target_transform = target_transform + self.size = size + + self.classes = classes + + self.paths_to_images = [] + self.labels = [] + + for c in self.classes: + paths_to_class = list(Path(self.path, c).glob('*.jpg')) + self.labels += [c]*len(paths_to_class) + self.paths_to_images += paths_to_class + self.labels = np.array(self.labels) + + def __len__(self): + return len(self.labels) + + def __getitem__(self, idx): + anchor_label = self.labels[idx] + + positive_index_list = [i for i, x in enumerate(self.labels) if x == anchor_label] + negative_index_list = [i for i, x in enumerate(self.labels) if x != anchor_label] + + positive_idx = np.random.choice(positive_index_list) + negative_idx = np.random.choice(negative_index_list) + + images = [] + for i in [idx, positive_idx, negative_idx]: + images += [read_image(str(self.paths_to_images[i])).float()] + + transform = transforms.Compose([ + SquarePad(), + transforms.Resize(self.size), + Normalize01() + ]) + + for i, image in enumerate(images): + images[i] = transform(image) + + if self.transform_aug is not None: + for i, image in enumerate(images): + images[i] = self.transform_aug(image) + + return images, anchor_label + +batch_size = 1 +dataset = CustomDataset(path_to_dataset, classes=classes, size=(64, 64)) +loader = DataLoader(dataset, batch_size=batch_size, shuffle=True) + +class EmbeddingModel(nn.Module): + def __init__(self, emb_dim=128): + super(EmbeddingModel, self).__init__() + self.conv = nn.Sequential( + nn.Conv2d(3, 16, 3), + nn.BatchNorm2d(16), + nn.PReLU(), + nn.MaxPool2d(2), + + nn.Conv2d(16, 32, 3), + nn.BatchNorm2d(32), + nn.PReLU(32), + nn.MaxPool2d(2), + + nn.Conv2d(32, 64, 3), + nn.PReLU(), + nn.BatchNorm2d(64), + nn.MaxPool2d(2) + ) + + self.fc = nn.Sequential( + nn.Linear(64*6*6, 256), + nn.PReLU(), + nn.Linear(256, emb_dim) + ) + + def forward(self, x): + x = self.conv(x) + x = x.view(-1, 64*6*6) + x = self.fc(x) + return x + + +if torch.cuda.is_available(): + print('Using GPU.') + device = 'cuda' +else: + print("CUDA not detected, using CPU.") + device = 'cpu' + +model_embedding = EmbeddingModel() + +model_embedding.load_state_dict(torch.load(config["embedding_model"])) +model_embedding.to(device) +model_embedding.eval() + +X = [] +labels = [] +#images = [] +for step, ((batch, _, _), label) in enumerate(loader): + X += [*model_embedding(batch.to(device)).cpu().detach().numpy()] + labels += [*label] + for x in batch: + x -= x.min() + x /= x.max() + #images += [transforms.functional.to_pil_image(x)] + +X = np.array(X) +labels = np.array(labels) + +model = kNN(config["n_neighbors"]) +model.fit(X, labels) + +with open(b'./model_classifier.obj', 'wb') as file: + pickle.dump(model, file) + +score = model.score(X, labels) +print(f'Score: {score:.4f}') diff --git a/train-embedding.py b/train-embedding.py new file mode 100644 index 00000000..976b2a23 --- /dev/null +++ b/train-embedding.py @@ -0,0 +1,265 @@ +import time +import torch +import random +import numpy as np +import pandas as pd +import torch.nn as nn +import torch.optim as optim +import matplotlib.pyplot as plt +import matplotlib as mpl +from pathlib import Path +import json +from torch.utils.data import Dataset, DataLoader +from torchvision.io import read_image +from torchvision import transforms +from PIL import Image, ImageOps + +from sklearn.manifold import TSNE +from sklearn.decomposition import PCA + +with open('embedding_config.json') as config_file: + config = json.load(config_file) + +if config["env"] == "colab": + from tqdm.notebook import tqdm +elif config["env"] == "": + from tqdm import tqdm + +path_to_dataset = Path(config["path_to_dataset"]) + +if len(config["classes"]) == 0: + classes = sorted([x.name for x in path_to_dataset.iterdir() if x.is_dir()]) +else: + classes = config["classes"] + + +class SquarePad: + def __call__(self, image): + _, w, h = image.size() + max_wh = max(w, h) + hp = int((max_wh - w) / 2) + vp = int((max_wh - h) / 2) + padding = (vp, hp, vp, hp) + return transforms.functional.pad(image, padding, 0, 'constant') + +class Normalize01: + def __call__(self, image): + image -= image.min() + image /= image.max() + return image + +class CustomDataset(Dataset): + def __init__(self, path, classes = None, augmentations=None, target_transform=None, size = (64, 64)): + self.path = path # path to directories + + self.transform_aug = augmentations + self.target_transform = target_transform + self.size = size + + self.classes = classes + + self.paths_to_images = [] + self.labels = [] + + for c in self.classes: + paths_to_class = list(Path(self.path, c).glob('*.jpg')) + self.labels += [c]*len(paths_to_class) + self.paths_to_images += paths_to_class + self.labels = np.array(self.labels) + + def __len__(self): + return len(self.labels) + + def __getitem__(self, idx): + anchor_label = self.labels[idx] + + positive_index_list = [i for i, x in enumerate(self.labels) if x == anchor_label] + negative_index_list = [i for i, x in enumerate(self.labels) if x != anchor_label] + + positive_idx = np.random.choice(positive_index_list) + negative_idx = np.random.choice(negative_index_list) + + images = [] + for i in [idx, positive_idx, negative_idx]: + images += [read_image(str(self.paths_to_images[i])).float()] + + transform = transforms.Compose([ + SquarePad(), + transforms.Resize(self.size), + Normalize01() + ]) + + for i, image in enumerate(images): + images[i] = transform(image) + + if self.transform_aug is not None: + for i, image in enumerate(images): + images[i] = self.transform_aug(image) + + return images, anchor_label + +class EmbeddingModel(nn.Module): + def __init__(self, emb_dim=128): + super(EmbeddingModel, self).__init__() + self.conv = nn.Sequential( + nn.Conv2d(3, 16, 3), + nn.BatchNorm2d(16), + nn.PReLU(), + nn.MaxPool2d(2), + + nn.Conv2d(16, 32, 3), + nn.BatchNorm2d(32), + nn.PReLU(32), + nn.MaxPool2d(2), + + nn.Conv2d(32, 64, 3), + nn.PReLU(), + nn.BatchNorm2d(64), + nn.MaxPool2d(2) + ) + + self.fc = nn.Sequential( + nn.Linear(64*6*6, 256), + nn.PReLU(), + nn.Linear(256, emb_dim) + ) + + def forward(self, x): + x = self.conv(x) + x = x.view(-1, 64*6*6) + x = self.fc(x) + return x + +augmentations = None +if config["augmentations"]: + augmentations = transforms.Compose( + [ + transforms.RandomHorizontalFlip(), + transforms.RandomAdjustSharpness(sharpness_factor=2), + transforms.RandomAutocontrast(), + transforms.ColorJitter(brightness=0.3) + ] + ) + +batch_size = config["batch_size"] + +dataset = CustomDataset(path_to_dataset, augmentations=augmentations, + classes=classes, size=(64, 64)) + +train_size = int(config["train_size"] * len(dataset)) +test_size = len(dataset) - train_size + +train_dataset, test_dataset = torch.utils.data.random_split(dataset, [train_size, test_size]) + +train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) +test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=True) + + +if torch.cuda.is_available(): + print('Using GPU.') + device = 'cuda' +else: + print("CUDA not detected, using CPU.") + device = 'cpu' + +embedding_dims = config["embedding_dims"] +epochs = config["epochs"] + +model = EmbeddingModel(embedding_dims).to(device) + +optimizer = optim.Adam(model.parameters(), lr=config["lr"]) +triplet_loss = nn.TripletMarginLoss(margin=config["triplet-loss-margin"], p=config["triplet-loss-p"]) + +model.train() + +train_loss = [] +test_loss = [] +epoch_all_loss = [] + +for epoch in range(epochs): + + train_one_epoch_loss = [] + test_one_epoch_loss = [] + + for step, ((anchor_image, positive_image, negative_image), anchor_label) in enumerate(train_loader): + + anchor_image = anchor_image.to(device) + positive_image = positive_image.to(device) + negative_image = negative_image.to(device) + + optimizer.zero_grad() + + anchor_pred = model(anchor_image) + positive_pred = model(positive_image) + negative_pred = model(negative_image) + + loss = triplet_loss(anchor_pred, positive_pred, negative_pred) + loss.backward() + optimizer.step() + loss = loss.cpu().detach().numpy() + + train_one_epoch_loss += [loss] + train_loss += [loss] + + for step, ((anchor_image, positive_image, negative_image), anchor_label) in enumerate(test_loader): + + anchor_image = anchor_image.to(device) + positive_image = positive_image.to(device) + negative_image = negative_image.to(device) + + anchor_pred = model(anchor_image) + positive_pred = model(positive_image) + negative_pred = model(negative_image) + + loss = triplet_loss(anchor_pred, positive_pred, negative_pred) + loss = loss.cpu().detach().numpy() + + test_one_epoch_loss += [loss] + test_loss += [loss] + + print(f"Epoch: {epoch+1}/{epochs} - Training loss: {np.mean(train_one_epoch_loss):.4f} - Test loss: {np.mean(test_one_epoch_loss):.4f}") + +train_loss = np.array(train_loss) +test_loss = np.array(test_loss) +q = 10 +plt.plot(np.convolve(train_loss, np.ones(len(train_loss) - len(test_loss) + q)/(len(train_loss) - len(test_loss) + q), mode = 'valid'), legend = 'Train loss') +plt.plot(np.convolve(test_loss, np.ones(q)/q, mode = 'valid'), legend = 'Test loss') +plt.legend() +plt.title("Epoch loss") +plt.savefig("embedding_loss.pdf") + +if config["visualize"]: + + X_train = [] + labels_train = [] + images_train = [] + for step, ((batch, _, _), label) in enumerate(train_loader): + X_train += [*model(batch.to(device)).cpu().detach().numpy()] + labels_train += [*label] + for x in batch: + x -= x.min() + x /= x.max() + images_train += [transforms.functional.to_pil_image(x)] + X_train = np.array(X_train) + labels_train = np.array(labels_train) + + pca = PCA(n_components=config["pca_components"]) + X_train_pca = pca.fit_transform(X_train) + + tsne = TSNE(n_components=2, learning_rate = 'auto', init = 'pca') + X_train_tsne = tsne.fit_transform(X_train_pca) + + cmap=plt.get_cmap('tab20') + colors = cmap(np.linspace(0, 1, len(classes))) + classes_to_colors = dict(zip(classes, colors)) + + plt.figure(figsize=(15, 10)) + for cls in classes: + plt.scatter(*X_train_tsne[labels_train == cls].T, color = classes_to_colors[cls], label = cls, s = 1); + + legend = plt.legend(fontsize=10) + for i in range(len(classes)): + legend.legendHandles[i]._sizes = [30] + + plt.savefig('visualized_simple.pdf') + diff --git a/visualized_simple.pdf b/visualized_simple.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6b2750628e5970bc1c9f679ebe3c6aaaf0c68f04 GIT binary patch literal 23115 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