panopticml 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
panopticml/__init__.py ADDED
File without changes
@@ -0,0 +1 @@
1
+ from .clustering import make_clusters
@@ -0,0 +1,122 @@
1
+ import faiss
2
+ import numpy as np
3
+ import torch
4
+
5
+ from panoptic.models import Vector
6
+ from panoptic.models.results import ScoreList, Score, Group
7
+ from ..utils import similarity_matrix
8
+
9
+
10
+ def make_clusters(vectors: list[Vector], **kwargs) -> (list[list[str]], list[int]):
11
+ res_clusters = []
12
+ res_distances = []
13
+ vectors, sha1 = zip(*[(i.data, i.sha1) for i in vectors])
14
+ sha1 = np.asarray(sha1)
15
+ clusters: np.ndarray
16
+
17
+ clusters, distances = _make_clusters_faiss(vectors, **kwargs)
18
+
19
+ for cluster in list(set(clusters)):
20
+ sha1_cluster = sha1[clusters == cluster]
21
+ current_cluster_distances = distances[clusters == cluster]
22
+ if distances is not None:
23
+ res_distances.append(np.mean(current_cluster_distances))
24
+ res_clusters.append(list(sha1_cluster))
25
+ # sort clusters by distances
26
+ sorted_clusters = [cluster for _, cluster in sorted(zip(res_distances, res_clusters))]
27
+ return sorted_clusters, sorted(res_distances)
28
+
29
+
30
+ def _make_clusters_faiss(vectors, nb_clusters=6, **kwargs) -> (np.ndarray, np.ndarray):
31
+ def _make_single_kmean(vectors, nb_clusters):
32
+ kmean = faiss.Kmeans(vectors.shape[1], nb_clusters, niter=20, verbose=False)
33
+ kmean.train(vectors)
34
+ return kmean.index.search(vectors, 1)
35
+
36
+ vectors = np.asarray(vectors)
37
+ if nb_clusters == -1:
38
+ import hdbscan
39
+ clusterer = hdbscan.HDBSCAN(min_cluster_size=5, gen_min_span_tree=True)
40
+ clusterer.fit(vectors)
41
+ indices = clusterer.labels_
42
+ probabilities = clusterer.probabilities_
43
+ distances = np.zeros_like(probabilities, dtype=np.float32)
44
+ unique_clusters = np.unique(indices)
45
+ # compute distances just like the one returned by kmeans to have consistent metrics
46
+ for cluster_id in unique_clusters:
47
+ if cluster_id == -1:
48
+ distances[indices == -1] = 100.0
49
+ continue
50
+ cluster_mask = (indices == cluster_id)
51
+ cluster_vectors = vectors[cluster_mask]
52
+ cluster_probabilities = probabilities[cluster_mask]
53
+ center_local_index = np.argmax(cluster_probabilities)
54
+ center_vector = cluster_vectors[center_local_index].reshape(1, -1)
55
+ dists = faiss.pairwise_distances(center_vector, cluster_vectors)[0]
56
+ distances[cluster_mask] = dists
57
+ else:
58
+ distances, indices = _make_single_kmean(vectors, nb_clusters)
59
+ return indices.flatten(), distances.flatten()
60
+
61
+
62
+ def cluster_by_text(image_vectors: list[Vector], text_vectors: list[np.array], text_labels: list[str]) -> list[Group]:
63
+ vectors, sha1s = zip(*[(i.data, i.sha1) for i in image_vectors])
64
+ sha1s_array = np.asarray(sha1s)
65
+
66
+ similarities, closest_text_indices = similarity_matrix(vectors, text_vectors)
67
+
68
+ clusters = []
69
+ distances = []
70
+ clusters_text = []
71
+ cluster_sims = []
72
+
73
+ for text_index in closest_text_indices.unique():
74
+ clusters_text.append(text_labels[text_index])
75
+ cluster = sha1s_array[closest_text_indices == text_index]
76
+
77
+ # similarities of each image inside the cluster and the text
78
+ cluster_sim = similarities[closest_text_indices == text_index]
79
+ sorted_sim = cluster_sim.sort(descending=True).values
80
+ cluster_sims.append([round(x, 2) for x in sorted_sim.tolist()])
81
+
82
+ # sort cluster by descending similarity
83
+ sorting_index = cluster_sim.argsort(descending=True)
84
+ sorted_cluster = cluster[sorting_index]
85
+ if type(sorted_cluster) is not np.ndarray:
86
+ sorted_cluster = np.array([sorted_cluster])
87
+ clusters.append(sorted_cluster)
88
+
89
+ # compute mean distance of the images
90
+ distance = float((1 - torch.mean(cluster_sim)) * 100)
91
+ distances.append(distance)
92
+
93
+ groups = []
94
+ for cluster, distance, name, cluster_sim in zip(clusters, distances, clusters_text, cluster_sims):
95
+ similarities = ScoreList(min=0, max=1, max_is_best=True, values=cluster_sim)
96
+ group = Group(score=Score(distance, min=0, max=200, max_is_best=False,
97
+ description="Mean distance between the images of this cluster and the queried text"),
98
+ scores=similarities)
99
+ group.sha1s = list(cluster)
100
+ group.name = f"Cluster {name}"
101
+ groups.append(group)
102
+
103
+ return groups
104
+
105
+ def custom_range(min_i, max_i, steps, increments):
106
+ """
107
+ Generate a range of values from min_i to max_i with a variable increment for each step
108
+ :param min_i: first value
109
+ :param max_i: last value
110
+ :param steps: values for which increment should change
111
+ :param increments: increments values
112
+ :return:
113
+ """
114
+ i = min_i
115
+ current_step = 0
116
+ current_incr = 1
117
+ while i < max_i:
118
+ yield i
119
+ if i >= steps[current_step] and current_step < len(steps) - 1:
120
+ current_incr = increments[current_step]
121
+ current_step += 1
122
+ i += current_incr
@@ -0,0 +1,72 @@
1
+ import os
2
+ import pickle
3
+
4
+ import faiss
5
+ import numpy as np
6
+
7
+ from .similarity import get_text_vectors
8
+ from ..models import VectorType
9
+ from panoptic.core.plugin.plugin import APlugin
10
+
11
+
12
+ class FaissTree:
13
+ def __init__(self, index: faiss.IndexFlatIP, labels: list[str]):
14
+ self.index = index
15
+ self.labels = labels
16
+
17
+ def query(self, vectors: list[np.ndarray], k=999999):
18
+ vector_center = np.mean(vectors, axis=0)
19
+ vector = np.asarray([vector_center])
20
+
21
+ real_k = min(k, len(self.labels))
22
+ vector = vector.reshape(1, -1)
23
+ dist, ind = self.index.search(vector, real_k)
24
+ indices = [x for x in ind[0]]
25
+ distances = [x for x in dist[0]] # avoid some strange overflow behavior
26
+ return [{'sha1': self.labels[i], 'dist': float('%.2f' % (distances[index]))} for index, i in
27
+ enumerate(indices)]
28
+
29
+ def query_texts(self, texts: list[str], transformer):
30
+ text_vectors = get_text_vectors(texts, transformer)
31
+ return self.query(text_vectors)
32
+
33
+
34
+ def gen_tree_file_name(vec_type: VectorType):
35
+ return f"{vec_type.value}_faiss_tree.pkl"
36
+
37
+
38
+ async def create_faiss_tree(plugin: APlugin, vec_type: VectorType):
39
+ project = plugin.project
40
+ name = gen_tree_file_name(vec_type)
41
+ vectors = await project.get_vectors(plugin.name, vector_type=vec_type.value)
42
+
43
+ if vectors is None or len(vectors) == 0:
44
+ return
45
+ vec_data, sha1_list = zip(*[(i.data, i.sha1) for i in vectors])
46
+ vec_np_arr = np.asarray(vec_data)
47
+ faiss.normalize_L2(vec_np_arr)
48
+
49
+ # create the faiss index based on this post: https://anttihavanko.medium.com/building-image-search-with-openai-clip-5a1deaa7a6e2
50
+ vector_size = vec_np_arr.shape[1]
51
+ index = faiss.IndexFlatIP(vector_size)
52
+ # faiss.ParameterSpace().set_index_parameter(index, 'nprobe', 100)
53
+ index.add(np.asarray(vec_np_arr))
54
+
55
+ tree = FaissTree(index, sha1_list)
56
+
57
+ with open(os.path.join(plugin.data_path, name), 'wb') as f:
58
+ pickle.dump(tree, f)
59
+
60
+ return FaissTree(index=index, labels=sha1_list)
61
+
62
+
63
+ def load_faiss_tree(plugin: APlugin, vec_type: VectorType) -> FaissTree | None:
64
+ name = gen_tree_file_name(vec_type)
65
+ path = os.path.join(plugin.data_path, name)
66
+ if not os.path.exists(path):
67
+ return None
68
+ try:
69
+ with open(path, 'rb') as f:
70
+ return pickle.load(f)
71
+ except ModuleNotFoundError:
72
+ return None
@@ -0,0 +1,11 @@
1
+ import numpy as np
2
+
3
+
4
+ def get_text_vectors(texts: [str], transformer):
5
+ vectors = []
6
+ if transformer.can_handle_text:
7
+ for text in texts:
8
+ vectors.append(transformer.to_text_vector(text))
9
+ else:
10
+ raise ValueError(f"The selected transformer {transformer.name_or_path} does not support text vectors.")
11
+ return np.asarray(vectors)
@@ -0,0 +1,131 @@
1
+ from enum import Enum
2
+
3
+ import torch
4
+ from PIL import Image
5
+ import numpy as np
6
+
7
+ from ....panopticml.utils import resolve_device
8
+
9
+
10
+ class TransformerName(Enum):
11
+ mobilenet = "mobilenet"
12
+ clip = "clip"
13
+ siglip = "siglip"
14
+ dinov2 = "dinov2"
15
+ auto = "auto"
16
+
17
+ def get_transformer(model: TransformerName=TransformerName.clip, hugging_face_model=None):
18
+ match model:
19
+ case TransformerName.mobilenet:
20
+ return GoogleTransformer()
21
+ case TransformerName.clip:
22
+ return CLIPTransformer()
23
+ case TransformerName.siglip:
24
+ return SIGLIPTransformer()
25
+ case TransformerName.dinov2:
26
+ return Dinov2Transformer()
27
+ case TransformerName.auto:
28
+ return AutoTransformer(hugging_face_model)
29
+
30
+ class Transformer(object):
31
+ def __init__(self):
32
+ import torch
33
+ from transformers import logging
34
+ logging.set_verbosity_error()
35
+ self.device = resolve_device()
36
+ self.tokenizer = None
37
+ self.processor = None
38
+ self.model = None
39
+
40
+ @property
41
+ def can_handle_text(self):
42
+ return False
43
+
44
+ class AutoTransformer(Transformer):
45
+ def __init__(self, hugging_face_model=None):
46
+ super().__init__()
47
+ from transformers import AutoModel, AutoProcessor
48
+ if hugging_face_model:
49
+ self.model = AutoModel.from_pretrained(hugging_face_model).to(self.device)
50
+ self.processor = AutoProcessor.from_pretrained(hugging_face_model)
51
+ self.name = hugging_face_model
52
+
53
+ @property
54
+ def can_handle_text(self):
55
+ return True
56
+
57
+ def to_vector(self, image: Image) -> np.ndarray:
58
+ inputs = self.processor(images=[image], return_tensors="pt")
59
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
60
+ with torch.no_grad():
61
+ image_features = self.model.get_image_features(**inputs)
62
+ image_features = image_features / image_features.norm(dim=-1, keepdim=True)
63
+ return image_features.cpu().numpy().flatten()
64
+
65
+ def to_text_vector(self, text: str) -> np.ndarray:
66
+ inputs = self.processor(text=[text], return_tensors="pt")
67
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
68
+ with torch.no_grad():
69
+ text_features = self.model.get_text_features(**inputs)
70
+ text_features = text_features / text_features.norm(dim=-1, keepdim=True)
71
+ return text_features.cpu().numpy().flatten()
72
+
73
+ class GoogleTransformer(Transformer):
74
+ def __init__(self):
75
+ super().__init__()
76
+ from transformers import MobileNetV2Model, AutoImageProcessor
77
+ self.model = MobileNetV2Model.from_pretrained("google/mobilenet_v2_1.0_224")
78
+ self.processor = AutoImageProcessor.from_pretrained("google/mobilenet_v2_1.0_224")
79
+ self.name = "MobileNetV2"
80
+
81
+ @property
82
+ def can_handle_text(self):
83
+ return False
84
+
85
+ def to_vector(self, image: Image) -> np.ndarray:
86
+ input1 = self.processor(images=image, return_tensors="pt")
87
+ output1 = self.model(**input1)
88
+ pooled_output1 = output1[1].detach().numpy()
89
+ vector = pooled_output1.flatten()
90
+ return vector
91
+
92
+ class CLIPTransformer(AutoTransformer):
93
+ def __init__(self):
94
+ model_name = "openai/clip-vit-base-patch32"
95
+ super().__init__(model_name)
96
+ self.name = "CLIP"
97
+
98
+
99
+ @property
100
+ def can_handle_text(self):
101
+ return True
102
+
103
+ class SIGLIPTransformer(AutoTransformer):
104
+ def __init__(self):
105
+ model_name = "google/siglip2-so400m-patch16-naflex"
106
+ super().__init__(model_name)
107
+ self.name = "SIGLIP"
108
+
109
+ @property
110
+ def can_handle_text(self):
111
+ return True
112
+
113
+
114
+ class Dinov2Transformer(Transformer):
115
+ def __init__(self):
116
+ super().__init__()
117
+ from transformers import AutoModel, AutoImageProcessor
118
+ ckpt = "facebook/dinov2-base"
119
+ self.model = AutoModel.from_pretrained(ckpt).to(self.device)
120
+ self.processor = AutoImageProcessor.from_pretrained(ckpt, use_fast=True)
121
+ self.name = "Dinov2"
122
+
123
+ @property
124
+ def can_handle_text(self):
125
+ return False
126
+
127
+ def to_vector(self, image: Image) -> np.ndarray:
128
+ inputs = self.processor(images=image, return_tensors="pt").to(self.device)
129
+ with torch.no_grad():
130
+ outputs = self.model(**inputs)
131
+ return outputs.last_hidden_state.mean(dim=1).cpu().numpy()[0]
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .utils import preprocess_image
7
+ from .models import VectorType
8
+
9
+ if TYPE_CHECKING:
10
+ from . import PanopticML
11
+
12
+ import aiofiles
13
+
14
+ from panoptic.core.task.task import Task
15
+ from panoptic.models import Instance, Vector
16
+
17
+ logger = logging.getLogger('PanopticML:VectorTask')
18
+
19
+
20
+ class ComputeVectorTask(Task):
21
+ def __init__(self, plugin: PanopticML, source: str, type_: VectorType, instance: Instance,
22
+ data_path: str):
23
+ super().__init__()
24
+ self.plugin = plugin
25
+ self.project = plugin.project
26
+ self.source = source
27
+ self.type = type_
28
+ self.instance = instance
29
+ self.transformer = self.plugin.transformer
30
+ self.name = f'{self.transformer.name} Vectors ({type_.value})'
31
+ self.data_path = data_path
32
+ self.key += f"_{type_.value}"
33
+
34
+ async def run(self):
35
+ instance = self.instance
36
+ exist = await self.project.vector_exist(self.source, self.type.value, instance.sha1)
37
+ if exist:
38
+ return
39
+
40
+ image_data = await self.project.get_project().db.get_large_image(instance.sha1)
41
+ if not image_data:
42
+ file = instance.url
43
+ async with aiofiles.open(file, mode='rb') as file:
44
+ image_data = await file.read()
45
+
46
+ vector_data = None
47
+ vector_data = await self._async(self.compute_image_clip, image_data)
48
+
49
+ if vector_data is None:
50
+ return
51
+ vector = Vector(self.source, self.type.name, instance.sha1, vector_data)
52
+ res = await self.project.add_vector(vector)
53
+ del vector
54
+ return res
55
+
56
+ async def run_if_last(self):
57
+ await self.plugin._update_tree(self.type)
58
+
59
+ def compute_image_clip(self, image_data: bytes):
60
+ image = preprocess_image(image_data, self.type)
61
+ vector = self.transformer.to_vector(image)
62
+
63
+ del image
64
+ return vector
panopticml/models.py ADDED
@@ -0,0 +1,6 @@
1
+ from enum import Enum
2
+
3
+
4
+ class VectorType(Enum):
5
+ rgb = 'rgb'
6
+ greyscale = 'greyscale'
@@ -0,0 +1,340 @@
1
+ from typing import Dict
2
+
3
+ import numpy as np
4
+ import requests
5
+ from PIL import Image
6
+ from pydantic import BaseModel
7
+
8
+ from panoptic.core.plugin.plugin import APlugin
9
+ from panoptic.core.plugin.plugin_project_interface import PluginProjectInterface
10
+ from panoptic.models import Instance, ActionContext, PropertyId, PropertyType
11
+ from panoptic.models.results import Group, ActionResult, Notif, NotifType, NotifFunction, ScoreList, Score
12
+ from panoptic.utils import group_by_sha1
13
+
14
+ from .compute import make_clusters
15
+ from .compute.clustering import cluster_by_text
16
+ from .compute.faiss_tree import load_faiss_tree, create_faiss_tree, FaissTree
17
+ from .compute.similarity import get_text_vectors
18
+ from .compute.transformers import get_transformer, TransformerName
19
+ from .compute_vector_task import ComputeVectorTask
20
+ from .models import VectorType
21
+ from .utils import is_image_url
22
+
23
+
24
+ class PluginParams(BaseModel):
25
+ """
26
+ @greyscale: if this is checked, vectors can be recomputed but this time images will be converted to greyscale before
27
+ @model: the name of the transformer to user, values: clip | mobilenet | siglip | auto, default to clip, if using auto, need to specify a huggingface model name, for instance: google/siglip2-base-patch16-224
28
+ """
29
+ greyscale: bool = False
30
+ model: str = "clip"
31
+ hugging_face_model: str = None
32
+
33
+
34
+ class PanopticML(APlugin):
35
+ """
36
+ Default Machine Learning plugin for Panoptic
37
+ Uses CLIP to generate vectors and FAISS for clustering / similarity functions
38
+ """
39
+
40
+ def __init__(self, project: PluginProjectInterface, plugin_path: str, name: str):
41
+ super().__init__(name=name, project=project, plugin_path=plugin_path)
42
+ self.params: PluginParams = PluginParams()
43
+
44
+ self.project.on_instance_import(self.compute_image_vectors_on_import)
45
+ self.project.on_folder_delete(self.rebuild_trees)
46
+ self.add_action_easy(self.find_images, ['similar'])
47
+ self.add_action_easy(self.compute_clusters, ['group'])
48
+ self.add_action_easy(self.cluster_by_tags, ['group'])
49
+ self.add_action_easy(self.find_duplicates, ['group'])
50
+ self.add_action_easy(self.search_by_text, ['execute'])
51
+ self._comp_vec_desc = self.add_action_easy(self.compute_vectors, ['execute'])
52
+ self._comp_all_vec_desc = self.add_action_easy(self.compute_all_vectors, ['execute'])
53
+
54
+ self.trees: Dict[VectorType, FaissTree] = {}
55
+ self._transformer = None
56
+
57
+ @property
58
+ def transformer(self):
59
+ if self._transformer is None:
60
+ self._transformer = self._load_transformer()
61
+ return self._transformer
62
+
63
+ async def start(self):
64
+ await super().start()
65
+
66
+ [await self._get_tree(t) for t in VectorType]
67
+
68
+ def _get_vector_func_notifs(self, vec_type: VectorType):
69
+ res = [
70
+ NotifFunction(self._comp_vec_desc.id,
71
+ ActionContext(ui_inputs={"vec_type": vec_type.value}),
72
+ message=f"Compute all vectors of type {vec_type.value}"),
73
+ NotifFunction(self._comp_all_vec_desc.id,
74
+ ActionContext(),
75
+ message="Compute vectors off all types")
76
+ ]
77
+ return res
78
+
79
+ async def compute_vectors(self, context: ActionContext, vec_type: VectorType):
80
+ """
81
+ Compute image vectors of selected vector type
82
+ """
83
+
84
+ instances = await self.project.get_instances(ids=context.instance_ids)
85
+ for i in instances:
86
+ await self._compute_image_vector(i, vec_type)
87
+
88
+ notif = Notif(type=NotifType.INFO,
89
+ name="ComputeVector",
90
+ message=f"Successfuly started compute of vectors of type {vec_type.value}")
91
+ return ActionResult(notifs=[notif])
92
+
93
+ async def compute_image_vectors_on_import(self, instance: Instance):
94
+ await self._compute_image_vector(instance, VectorType.rgb)
95
+ if self.params.greyscale:
96
+ await self._compute_image_vector(instance, VectorType.greyscale)
97
+
98
+ async def compute_all_vectors(self, context: ActionContext):
99
+ """
100
+ Compute image vectors of all supported types
101
+ """
102
+ res = [await self.compute_vectors(context, t) for t in VectorType]
103
+ return ActionResult(notifs=[n for r in res for n in r.notifs])
104
+
105
+ async def _compute_image_vector(self, instance: Instance, vector: VectorType):
106
+ task = ComputeVectorTask(self, self.name, vector, instance, self.data_path)
107
+ self.project.add_task(task)
108
+
109
+ async def compute_clusters(self, context: ActionContext, vec_type: VectorType = VectorType.rgb,
110
+ nb_clusters: int = 10, label_clusters: bool = False):
111
+ """
112
+ Computes images clusters with Faiss Kmeans
113
+ @nb_clusters: requested number of clusters
114
+ """
115
+ instances = await self.project.get_instances(context.instance_ids)
116
+ sha1_to_instance = group_by_sha1(instances)
117
+ sha1_to_ahash = {i.sha1: i.ahash for i in instances}
118
+ sha1s = list(sha1_to_instance.keys())
119
+
120
+ if not sha1s:
121
+ empty_notif = Notif(NotifType.ERROR, name="NoData", message="No instance found")
122
+ return ActionResult(notifs=[empty_notif])
123
+
124
+ vectors = await self.project.get_vectors(source=self.name, vector_type=vec_type.value, sha1s=sha1s)
125
+
126
+ if not vectors:
127
+ empty_notif = Notif(NotifType.ERROR,
128
+ name="NoData",
129
+ message=f"""For the clustering function image vectors are needed.
130
+ No such vectors ({vec_type.value}) could be found.
131
+ Compute the vectors and try again.) """,
132
+ functions=self._get_vector_func_notifs(vec_type))
133
+ return ActionResult(notifs=[empty_notif])
134
+ clusters, distances = make_clusters(vectors, method="kmeans", nb_clusters=nb_clusters)
135
+ groups = []
136
+ groups_images = []
137
+ labels = []
138
+ i = 0
139
+ # TODO: put back mistral when it's working properly
140
+ if label_clusters:
141
+ from .mistral_test import create_labels_from_group, generate_group_image
142
+ for cluster, distance in zip(clusters, distances):
143
+ group = Group(score=Score(min=0, max=100, max_is_best=False, value=distance))
144
+ if label_clusters:
145
+ images = [sha1_to_instance[sha1][0].url for sha1 in cluster[:20]]
146
+ groups_images.append(generate_group_image(images, i))
147
+ i += 1
148
+ group.sha1s = sorted(cluster, key=lambda sha1: sha1_to_ahash[sha1])
149
+ groups.append(group)
150
+ if len(groups_images) > 0:
151
+ labels = create_labels_from_group(groups_images)
152
+ for i, g in enumerate(groups):
153
+ g.name = f"Cluster {i}" if not len(labels) > 0 else "-".join(labels[i])
154
+
155
+ return ActionResult(groups=groups)
156
+
157
+ async def find_images(self, context: ActionContext, vec_type: VectorType = VectorType.rgb):
158
+ """
159
+ Find Similar images using Cosine distances.
160
+ dist: 0 -> images are considered highly dissimilar
161
+ dist: 1 -> images are considered identical
162
+ See: https://en.wikipedia.org/wiki/Cosine_similarity for more.
163
+ """
164
+ instances = await self.project.get_instances(context.instance_ids)
165
+ sha1s = [i.sha1 for i in instances]
166
+ ignore_sha1s = set(sha1s)
167
+ vectors = await self.project.get_vectors(source=self.name, vector_type=vec_type.value, sha1s=sha1s)
168
+
169
+ if not vectors:
170
+ return ActionResult(notifs=[Notif(
171
+ NotifType.ERROR,
172
+ name="NoData",
173
+ message=f"""For the similarity function image vectors are needed.
174
+ No such vectors ({vec_type.value}) could be found.
175
+ Compute the vectors and try again.) """,
176
+ functions=self._get_vector_func_notifs(vec_type))])
177
+
178
+ vector_datas = [x.data for x in vectors]
179
+
180
+ tree = await self._get_tree(vec_type)
181
+ if not tree:
182
+ notif = Notif(type=NotifType.ERROR, name="NoFaissTree",
183
+ message=f"No Faiss tree could be loaded for vec_type {vec_type.value}")
184
+ return ActionResult(notifs=[notif])
185
+
186
+ res = tree.query(vector_datas)
187
+ index = {r['sha1']: r['dist'] for r in res if r['sha1'] not in ignore_sha1s}
188
+
189
+ res_sha1s = list(index.keys())
190
+ res_scores = ScoreList(min=0, max=1, values=[index[sha1] for sha1 in res_sha1s],
191
+ max_is_best=True,
192
+ description="Similarity between 0 and 1. 1 is best")
193
+
194
+ res = Group(sha1s=res_sha1s, scores=res_scores)
195
+ return ActionResult(groups=[res])
196
+
197
+ async def search_by_text(self, context: ActionContext, vec_type: VectorType = VectorType.rgb, text: str = '', min_similarity: float = 0.5):
198
+ """Search image using text similarity"""
199
+ if text == '':
200
+ notif = Notif(type=NotifType.ERROR, name="EmptySearchText",
201
+ message="Please give a valid and not empty text search argument")
202
+ return ActionResult(notifs=[notif])
203
+
204
+ context_instances = await self.project.get_instances(context.instance_ids)
205
+ context_sha1s = [i.sha1 for i in context_instances]
206
+
207
+ tree = await self._get_tree(vec_type)
208
+ if not tree:
209
+ notif = Notif(type=NotifType.ERROR, name="NoFaissTree",
210
+ message=f"No Faiss tree could be loaded for vec_type {vec_type.value}")
211
+ return ActionResult(notifs=[notif])
212
+
213
+ if is_image_url(text):
214
+ im = Image.open(requests.get(text, stream=True).raw)
215
+ vec = self.transformer.to_vector(im)
216
+ resulting_images = tree.query([vec])
217
+ else:
218
+ try:
219
+ resulting_images = tree.query_texts([text], self.transformer)
220
+ except ValueError as e:
221
+ return ActionResult(notifs=[Notif(type=NotifType.ERROR, name="TextSimilarityError", message=str(e))])
222
+
223
+
224
+ # filter out images if they are not in the current context
225
+ filtered_instances = [inst for inst in resulting_images if inst['sha1'] in context_sha1s]
226
+
227
+ index = {r['sha1']: r['dist'] for r in filtered_instances}
228
+ res_sha1s = np.asarray(list(index.keys()))
229
+ res_scores = np.asarray([index[sha1] for sha1 in res_sha1s])
230
+
231
+ # remap score since text to image similary tends to be between 0.1 and 0.4 and filter by similarity
232
+ remaped_scores = np.around(np.interp(res_scores, [0, 0.375], [0, 1]), decimals=2)
233
+ final_scores = remaped_scores[remaped_scores >= min_similarity].tolist()
234
+ final_sha1s = res_sha1s[remaped_scores >= min_similarity].tolist()
235
+
236
+ scores = ScoreList(min=0, max=1, values=final_scores, description="Similarity between image and text never give less than 0.1 and more than 0.4, hence here the values, remapped between 0 and 1")
237
+ res = Group(sha1s=final_sha1s, scores=scores)
238
+ res.name = "Text Search: " + text
239
+ return ActionResult(groups=[res])
240
+
241
+ async def cluster_by_tags(self, context: ActionContext, tags: PropertyId, vec_type: VectorType = VectorType.rgb):
242
+ """Cluster images using a Tag/MultiTag property to guide the result"""
243
+ props = await self.project.get_properties(ids=[tags])
244
+ tag_prop = props[0]
245
+
246
+ if tag_prop.type != PropertyType.tag and tag_prop.type != PropertyType.multi_tags:
247
+ notif = Notif(type=NotifType.ERROR,
248
+ name="WrongPropertyType",
249
+ message=f"""Property: <{tag_prop.name}> is not of type Tag or MultiTags. This function only
250
+ accepts tag types properties. Please choose another property""")
251
+ return ActionResult(notifs=[notif])
252
+
253
+ instances = await self.project.get_instances(context.instance_ids)
254
+ sha1_to_instance = group_by_sha1(instances)
255
+ sha1s = list(sha1_to_instance.keys())
256
+ if not sha1s:
257
+ return None
258
+ # TODO: get tags text from the PropertyId
259
+ tags_text = [t.value for t in await self.project.get_tags(property_ids=[tags])]
260
+ text_vectors = get_text_vectors(tags_text, self.transformer)
261
+ pano_vectors = await self.project.get_vectors(source=self.name, vector_type=vec_type.value, sha1s=sha1s)
262
+
263
+ if not pano_vectors:
264
+ return ActionResult(notifs=[Notif(
265
+ NotifType.ERROR,
266
+ name="NoData",
267
+ message=f"""The Cluster_By_Tags function needs image vectors.
268
+ No such vectors ({vec_type.value}) could be found.
269
+ Compute the vectors and try again.) """,
270
+ functions=self._get_vector_func_notifs(vec_type))])
271
+
272
+ groups = cluster_by_text(pano_vectors, text_vectors, tags_text)
273
+
274
+ return ActionResult(groups=groups)
275
+
276
+ async def find_duplicates(self, context: ActionContext, min_similarity: float = 0.95):
277
+ """
278
+ Create clusters with at least `min_similarity` between the images of the cluster
279
+ @min_similarity: the minimal similarity value between images of the cluster
280
+ """
281
+ # on récupère les vecteurs
282
+ # pour chaque vecteur on récupère ses plus similaires (150 pour test) puis on filtre tout ce qui est < min_similarity
283
+ # on marque tous les images dans le cluster pour ne pas les requêter à nouveau
284
+ instances = await self.project.get_instances(context.instance_ids)
285
+ sha1_to_instance = group_by_sha1(instances)
286
+ sha1s = list(sha1_to_instance.keys())
287
+ if not sha1s:
288
+ return None
289
+ # TODO: get tags text from the PropertyId
290
+ pano_vectors = await self.project.get_vectors(source=self.name, vector_type=VectorType.rgb.value, sha1s=sha1s)
291
+ vectors, sha1s = zip(*[(i.data, i.sha1) for i in pano_vectors])
292
+ already_in_clusters = set()
293
+ groups = []
294
+ for vector, sha1 in zip(vectors, sha1s):
295
+ if sha1 in already_in_clusters:
296
+ continue
297
+ tree = await self._get_tree(VectorType.rgb)
298
+ res = tree.query([vector.data], 150)
299
+ filtered = [r for r in res if r['dist'] >= min_similarity and r['sha1'] in sha1s]
300
+ res_sha1s = [r['sha1'] for r in filtered]
301
+ res_scores = [r['dist'] for r in filtered]
302
+ score_list = ScoreList(min=0, max=1, max_is_best=True, values=res_scores)
303
+ if len(res_sha1s) == 1:
304
+ continue
305
+ already_in_clusters.update(res_sha1s)
306
+ groups.append(Group(sha1s=res_sha1s, scores=score_list))
307
+ return ActionResult(groups=groups)
308
+
309
+ async def rebuild_trees(self, deleted):
310
+ for type_ in VectorType:
311
+ await self._update_tree(type_)
312
+
313
+ async def _get_tree(self, vec_type: VectorType):
314
+ tree = self.trees.get(vec_type)
315
+ if tree:
316
+ return tree
317
+ tree = load_faiss_tree(self, vec_type)
318
+ if tree:
319
+ self.trees[vec_type] = tree
320
+ return tree
321
+ tree = await create_faiss_tree(self, vec_type)
322
+ if tree:
323
+ self.trees[vec_type] = tree
324
+ return tree
325
+
326
+ async def _update_tree(self, vec_type: VectorType):
327
+ tree = await create_faiss_tree(self, vec_type)
328
+ self.trees[vec_type] = tree
329
+ print(f"updated {vec_type.value} faiss tree")
330
+ return tree
331
+
332
+ def _load_transformer(self):
333
+ if TransformerName[self.params.model] == TransformerName.auto and not self.params.hugging_face_model:
334
+ return ActionResult(notifs=[Notif(
335
+ NotifType.ERROR,
336
+ name="No hugging face model specified",
337
+ message=f"""
338
+ Automodel selected but no hugging face model provided
339
+ """)])
340
+ return get_transformer(TransformerName[self.params.model], self.params.hugging_face_model)
panopticml/utils.py ADDED
@@ -0,0 +1,72 @@
1
+ import io
2
+
3
+ import numpy as np
4
+ import torch
5
+ import re
6
+ from PIL import Image
7
+
8
+ from .models import VectorType
9
+
10
+
11
+ def preprocess_image(image_data: bytes, vector_type: VectorType = VectorType.rgb):
12
+ image = Image.open(io.BytesIO(image_data))
13
+ if vector_type == VectorType.rgb:
14
+ image = image.convert('RGB')
15
+ else:
16
+ image = image.convert('L').convert('RGB')
17
+ return image
18
+
19
+ def cosine_similarity(embedding1: np.array, embedding2: np.array):
20
+ """
21
+ Calcule la similarité cosinus entre deux embeddings normalisés
22
+
23
+ Args:
24
+ embedding1: premier embedding (normalisé)
25
+ embedding2: deuxième embedding (normalisé)
26
+
27
+ Returns:
28
+ float: similarité cosinus (entre -1 et 1)
29
+ """
30
+ # Pour des embeddings normalisés, similarité cosinus = produit scalaire
31
+ embedding1_torch = torch.from_numpy(embedding1).squeeze()
32
+ embedding2_torch = torch.from_numpy(embedding2).squeeze()
33
+
34
+ return torch.dot(embedding1_torch, embedding2_torch).item()
35
+
36
+
37
+ def similarity_matrix(vectors1: list[np.array], vectors2: list[np.array]) -> (torch.Tensor, torch.Tensor):
38
+ """
39
+ Create a similarity matrix from two list of normalized np vectors
40
+ Returns two tensors, one for best scores and one for best_indices of vectors1 into vectors2
41
+ i.e for each vector1 we'll know the closest vector of vectors2 and the similarity between them
42
+ """
43
+ astorch1 = torch.from_numpy(np.vstack(vectors1))
44
+ astorch2 = torch.from_numpy(np.vstack(vectors2))
45
+ matrix = torch.mm(astorch1, astorch2.T)
46
+ best_scores, best_indices = torch.max(matrix, dim=1)
47
+ return best_scores, best_indices
48
+
49
+ def resolve_device():
50
+ device = 'cpu'
51
+ if torch.cuda.is_available():
52
+ device = 'cuda'
53
+ # TODO: when silicon bugs are working again put it back
54
+ # elif torch.backends.mps.is_available():
55
+ # device = 'mps'
56
+ return device
57
+
58
+ def is_image_url(url):
59
+ pattern = re.compile(
60
+ r'''(?xi)
61
+ \b
62
+ https?:// # protocole http ou https
63
+ [\w.-]+(?:\.[\w.-]+)+ # domaine
64
+ (?:/[^\s?#]*)* # chemin éventuel
65
+ /? # éventuellement un / final
66
+ [^\s?#]* # éventuellement un nom de fichier
67
+ \.(?:jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico) # extension image
68
+ (?:\?[^\s#]*)? # paramètres après ?
69
+ \b
70
+ '''
71
+ )
72
+ return re.match(pattern, url)
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: panopticml
3
+ Version: 0.0.2
4
+ Summary: A package for Panoptic ML
5
+ Project-URL: Homepage, https://github.com/CERES-Sorbonne/PanopticML
6
+ Project-URL: Bug Tracker, https://github.com/CERES-Sorbonne/PanopticML/issues
7
+ Author-email: Félix Alié <felix.alie@sorbonne-universite.fr>
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: Implementation :: CPython
18
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: faiss-cpu<1.11.0
21
+ Requires-Dist: hdbscan
22
+ Requires-Dist: mistralai
23
+ Requires-Dist: numpy; platform_system != 'Darwin'
24
+ Requires-Dist: numpy<=1.26.0; platform_system == 'Darwin'
25
+ Requires-Dist: scikit-learn; platform_system != 'Darwin'
26
+ Requires-Dist: scikit-learn==1.5.0; platform_system == 'Darwin'
27
+ Requires-Dist: scipy
28
+ Requires-Dist: torch; platform_system != 'Darwin'
29
+ Requires-Dist: torch==2.1.0; platform_system == 'Darwin'
30
+ Requires-Dist: torchvision
31
+ Requires-Dist: transformers<4.52.0
32
+ Description-Content-Type: text/markdown
33
+
34
+ # PanopticML
35
+
36
+ Default plugin for panoptic, provides all machine learning functionalities such as:
37
+ - image embeddings using OpenAI CLIP
38
+ - images clustering using FAISS Kmeans or HDBScan
39
+ - images to images similarity using FAISS Index L2
40
+ - text to images similarity using FAISS Index L2 + CLIP
41
+
42
+ ## Available models
43
+
44
+ Several models are available in this plugin to compute the embeddings:
45
+ - [google mobilenet](https://huggingface.co/google/mobilenet_v2_1.4_224): a really light model with lower performances but made to run on bad devices, no support for text similarity
46
+ - [openAI CLIP](https://huggingface.co/openai/clip-vit-large-patch14): default model for panoptic, good performances, runs well on most computers with a decent CPU, support text similarity and has a good understanding of semantics
47
+ - [meta dinosV2](https://huggingface.co/docs/transformers/en/model_doc/dinov2): model that will have better performances than CLIP on pure visual similarities but less understanding of themes and semantics, no support for text similarity, runs well on CPU
48
+ - [google SIGLIP2](https://huggingface.co/docs/transformers/main/model_doc/siglip2): computing heavy model, NVDIA GPU recommended but works also on CPU (it will take a long time to compute embeddings), way better results than CLIP on text similarity and semantics, and pretty good visual features
49
+ - auto transformers: want to tryout any huggingface multimodal model ? you can just provide its id to panopticML and should be able to use it directly
50
+
51
+ ## Clustering functions
52
+ - Kmeans: specify a number of clusters, really fast
53
+ - HDBScan: automatic number of clusters, a bit slower and excludes a lot of images
54
+ - Images to text: considering several texts, create a cluster per text and put each image to the text it is the closest to
55
+ - Find duplicates: create clusters with only images sharing a lot of similarity together, good to find duplicates or near duplicates
@@ -0,0 +1,13 @@
1
+ panopticml/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ panopticml/compute_vector_task.py,sha256=Fe2CRez6tXReJ46v0xREJRKY3RYm4iaIx-maUpr_FQE,1957
3
+ panopticml/models.py,sha256=JSex5wzu1S0iw1xnGJN1zdHo7ARnxXy2jLhHZ-mCQVs,92
4
+ panopticml/panoptic_ml.py,sha256=XH8mSLfcSBvpnBNtqrDenPBl-6FSomke-rTHBL4zyT0,16047
5
+ panopticml/utils.py,sha256=28qUMoBh-qaI8-Y8fIy0rFr3evTV6J1kr5dazehedcI,2470
6
+ panopticml/compute/__init__.py,sha256=Y5okbGIQHPYCal9curiu1-WFES29Qd2Lc9RY2V-BKBI,37
7
+ panopticml/compute/clustering.py,sha256=O1Yshea9D7PCqDE_bAMwKyBkAyPei5Wz_pS2xhayD-E,4937
8
+ panopticml/compute/faiss_tree.py,sha256=WY2oZJCiGMWbyVWIqaNEgsW16VXH5tt17Zf9P5vrpZ4,2408
9
+ panopticml/compute/similarity.py,sha256=u4GmoaWQqq-fBqChiHl6HsLfK8QqVZasIscbbzB9I7c,364
10
+ panopticml/compute/transformers/__init__.py,sha256=zCVjpY1E_afMbmB2bWLT1grPbOlqj7MsxDcDR1sOHec,4381
11
+ panopticml-0.0.2.dist-info/METADATA,sha256=XClZCxAWyjGZerkz3iyoeeCRPLiH9lrHZQj8cWqlGuc,3381
12
+ panopticml-0.0.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
+ panopticml-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any