ooferdoodles commited on
Commit
3f8e838
1 Parent(s): 3470f9f

initial commit

Browse files
Files changed (3) hide show
  1. app.py +44 -0
  2. changechip.py +738 -0
  3. requirements.txt +6 -0
app.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import gradio as gr
3
+
4
+ from changechip import *
5
+
6
+ app_port = os.getenv("APP_PORT", "7860")
7
+
8
+
9
+ def process(input_image, reference_image, resize_factor, output_alpha):
10
+ return pipeline(
11
+ (input_image, reference_image),
12
+ resize_factor=resize_factor,
13
+ output_alpha=output_alpha,
14
+ )
15
+
16
+
17
+ with gr.Blocks() as demo:
18
+ gr.Markdown("# ChangeChip")
19
+ gr.Markdown(
20
+ """
21
+ Welcome to ChangeChip! This tool allows you to detect defects on printed circuit boards (PCBs) by comparing an input image with a reference "golden sample" image.
22
+ Simply upload your images, adjust the settings if needed, and click "Run" to highlight any discrepancies.
23
+ """
24
+ )
25
+ with gr.Row():
26
+ with gr.Column(scale=1):
27
+ input_image = gr.Image(label="Input Image")
28
+ reference_image = gr.Image(label="Reference Image")
29
+ with gr.Accordion(label="Other Options", open=False):
30
+ resize_factor = gr.Slider(0.1, 1, 0.5, step=0.1, label="Resize Factor")
31
+ output_alpha = gr.Slider(0, 255, 50, step=1, label="Output Alpha")
32
+
33
+ with gr.Column(scale=2):
34
+ output_image = gr.Image(label="Output Image", scale=9)
35
+ btn = gr.Button("Run", scale=1)
36
+
37
+ btn.click(
38
+ fn=process,
39
+ inputs=[input_image, reference_image, resize_factor, output_alpha],
40
+ outputs=output_image,
41
+ )
42
+
43
+ if __name__ == "__main__":
44
+ demo.launch()
changechip.py ADDED
@@ -0,0 +1,738 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import cv2
3
+ import numpy as np
4
+
5
+ from skimage.exposure import match_histograms
6
+ from sklearn.cluster import KMeans, DBSCAN
7
+ from sklearn.decomposition import PCA
8
+
9
+ import matplotlib.pyplot as plt
10
+ import matplotlib.colors as mcolors
11
+ import seaborn as sns
12
+
13
+ import time
14
+
15
+
16
+ def resize_images(images, resize_factor=1.0):
17
+ """
18
+ Resizes the input and reference images based on the average dimensions of the two images and a resize factor.
19
+
20
+ Parameters:
21
+ images (tuple): A tuple containing two images (input_image, reference_image). Both images should be numpy arrays.
22
+ resize_factor (float): A factor by which to resize the images. Default is 1.0, which means the images will be resized to the average dimensions of the two images.
23
+
24
+ Returns:
25
+ tuple: A tuple containing the resized input and reference images.
26
+
27
+ Example:
28
+ >>> input_image = cv2.imread('input.jpg')
29
+ >>> reference_image = cv2.imread('reference.jpg')
30
+ >>> resized_images = resize_images((input_image, reference_image), resize_factor=0.5)
31
+
32
+ """
33
+ input_image, reference_image = images
34
+ average_width = (input_image.shape[1] + reference_image.shape[1]) * 0.5
35
+ average_height = (input_image.shape[0] + reference_image.shape[0]) * 0.5
36
+ new_shape = (
37
+ int(resize_factor * average_width),
38
+ int(resize_factor * average_height),
39
+ )
40
+
41
+ input_image = cv2.resize(input_image, new_shape, interpolation=cv2.INTER_AREA)
42
+ reference_image = cv2.resize(
43
+ reference_image, new_shape, interpolation=cv2.INTER_AREA
44
+ )
45
+
46
+ return input_image, reference_image
47
+
48
+
49
+ def homography(images, debug=False, output_directory=None):
50
+ """
51
+ Apply homography transformation to align two images.
52
+
53
+ Args:
54
+ images (tuple): A tuple containing two images, where the first image is the input image and the second image is the reference image.
55
+ debug (bool, optional): If True, debug images will be generated. Defaults to False.
56
+ output_directory (str, optional): The directory to save the debug images. Defaults to None.
57
+
58
+ Returns:
59
+ tuple: A tuple containing the aligned input image and the reference image.
60
+ """
61
+ input_image, reference_image = images
62
+ # Initiate SIFT detector
63
+ sift = cv2.SIFT_create()
64
+
65
+ # find the keypoints and descriptors with SIFT
66
+ input_keypoints, input_descriptors = sift.detectAndCompute(input_image, None)
67
+ reference_keypoints, reference_descriptors = sift.detectAndCompute(
68
+ reference_image, None
69
+ )
70
+ # BFMatcher with default params
71
+ bf = cv2.BFMatcher()
72
+ matches = bf.knnMatch(reference_descriptors, input_descriptors, k=2)
73
+
74
+ # Apply ratio test
75
+ good_draw = []
76
+ good_without_list = []
77
+ for m, n in matches:
78
+ if m.distance < 0.8 * n.distance: # 0.8 = a value suggested by David G. Lowe.
79
+ good_draw.append([m])
80
+ good_without_list.append(m)
81
+
82
+ # cv.drawMatchesKnn expects list of lists as matches.
83
+ if debug:
84
+ assert output_directory is not None, "Output directory must be provided"
85
+ os.makedirs(output_directory, exist_ok=True)
86
+ cv2.imwrite(
87
+ os.path.join(output_directory, "matching.png"),
88
+ cv2.drawMatchesKnn(
89
+ reference_image,
90
+ reference_keypoints,
91
+ input_image,
92
+ input_keypoints,
93
+ good_draw,
94
+ None,
95
+ flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS,
96
+ ),
97
+ )
98
+
99
+ # Extract location of good matches
100
+ reference_points = np.zeros((len(good_without_list), 2), dtype=np.float32)
101
+ input_points = reference_points.copy()
102
+
103
+ for i, match in enumerate(good_without_list):
104
+ input_points[i, :] = reference_keypoints[match.queryIdx].pt
105
+ reference_points[i, :] = input_keypoints[match.trainIdx].pt
106
+
107
+ # Find homography
108
+ h, _ = cv2.findHomography(input_points, reference_points, cv2.RANSAC)
109
+
110
+ # Use homography
111
+ height, width = reference_image.shape[:2]
112
+ white_reference_image = 255 - np.zeros(shape=reference_image.shape, dtype=np.uint8)
113
+ white_reg = cv2.warpPerspective(white_reference_image, h, (width, height))
114
+ blank_pixels_mask = np.any(white_reg != [255, 255, 255], axis=-1)
115
+ reference_image_registered = cv2.warpPerspective(
116
+ reference_image, h, (width, height)
117
+ )
118
+ if debug:
119
+ assert output_directory is not None, "Output directory must be provided"
120
+ cv2.imwrite(
121
+ os.path.join(output_directory, "aligned.png"), reference_image_registered
122
+ )
123
+
124
+ input_image[blank_pixels_mask] = [0, 0, 0]
125
+ reference_image_registered[blank_pixels_mask] = [0, 0, 0]
126
+
127
+ return input_image, reference_image_registered
128
+
129
+
130
+ def histogram_matching(images, debug=False, output_directory=None):
131
+ """
132
+ Perform histogram matching between an input image and a reference image.
133
+
134
+ Args:
135
+ images (tuple): A tuple containing the input image and the reference image.
136
+ debug (bool, optional): If True, save the histogram-matched image to the output directory. Defaults to False.
137
+ output_directory (str, optional): The directory to save the histogram-matched image. Defaults to None.
138
+
139
+ Returns:
140
+ tuple: A tuple containing the input image and the histogram-matched reference image.
141
+ """
142
+
143
+ input_image, reference_image = images
144
+
145
+ reference_image_matched = match_histograms(
146
+ reference_image, input_image, channel_axis=-1
147
+ )
148
+ if debug:
149
+ assert output_directory is not None, "Output directory must be provided"
150
+ cv2.imwrite(
151
+ os.path.join(output_directory, "histogram_matched.jpg"),
152
+ reference_image_matched,
153
+ )
154
+ reference_image_matched = np.asarray(reference_image_matched, dtype=np.uint8)
155
+ return input_image, reference_image_matched
156
+
157
+
158
+ def preprocess_images(images, resize_factor=1.0, debug=False, output_directory=None):
159
+ """
160
+ Preprocesses a list of images by performing the following steps:
161
+ 1. Resizes the images based on the given resize factor.
162
+ 2. Applies homography to align the resized images.
163
+ 3. Performs histogram matching on the aligned images.
164
+
165
+ Args:
166
+ images (tuple): A tuple containing the input image and the reference image.
167
+ resize_factor (float, optional): The factor by which to resize the images. Defaults to 1.0.
168
+ debug (bool, optional): Whether to enable debug mode. Defaults to False.
169
+ output_directory (str, optional): The directory to save the output images. Defaults to None.
170
+
171
+ Returns:
172
+ tuple: The preprocessed images.
173
+
174
+ Example:
175
+ >>> images = (input_image, reference_image)
176
+ >>> preprocess_images(images, resize_factor=0.5, debug=True, output_directory='output/')
177
+ """
178
+ start_time = time.time()
179
+ resized_images = resize_images(images, resize_factor)
180
+ aligned_images = homography(
181
+ resized_images, debug=debug, output_directory=output_directory
182
+ )
183
+ matched_images = histogram_matching(
184
+ aligned_images, debug=debug, output_directory=output_directory
185
+ )
186
+ print("--- Preprocessing time - %s seconds ---" % (time.time() - start_time))
187
+ return matched_images
188
+
189
+
190
+ # The returned vector_set goes later to the PCA algorithm which derives the EVS (Eigen Vector Space).
191
+ # Therefore, there is a mean normalization of the data
192
+ # jump_size is for iterating non-overlapping windows. This parameter should be eqaul to the window_size of the system
193
+ def find_vector_set(descriptors, jump_size, shape):
194
+ """
195
+ Find the vector set from the given descriptors.
196
+
197
+ Args:
198
+ descriptors (numpy.ndarray): The input descriptors.
199
+ jump_size (int): The jump size for sampling the descriptors.
200
+ shape (tuple): The shape of the descriptors.
201
+
202
+ Returns:
203
+ tuple: A tuple containing the vector set and the mean vector.
204
+ """
205
+ size_0, size_1 = shape
206
+ descriptors_2d = descriptors.reshape((size_0, size_1, descriptors.shape[1]))
207
+ vector_set = descriptors_2d[::jump_size, ::jump_size]
208
+ vector_set = vector_set.reshape(
209
+ (vector_set.shape[0] * vector_set.shape[1], vector_set.shape[2])
210
+ )
211
+ mean_vec = np.mean(vector_set, axis=0)
212
+ vector_set = vector_set - mean_vec # mean normalization
213
+ return vector_set, mean_vec
214
+
215
+
216
+ # returns the FSV (Feature Vector Space) which then goes directly to clustering (with Kmeans)
217
+ # Multiply the data with the EVS to get the entire data in the PCA target space
218
+ def find_FVS(descriptors, EVS, mean_vec):
219
+ """
220
+ Calculate the feature vector space (FVS) by performing dot product of descriptors and EVS,
221
+ and subtracting the mean vector from the result.
222
+
223
+ Args:
224
+ descriptors (numpy.ndarray): Array of descriptors.
225
+ EVS (numpy.ndarray): Eigenvalue matrix.
226
+ mean_vec (numpy.ndarray): Mean vector.
227
+
228
+ Returns:
229
+ numpy.ndarray: The calculated feature vector space (FVS).
230
+
231
+ """
232
+ FVS = np.dot(descriptors, EVS)
233
+ FVS = FVS - mean_vec
234
+ # print("\nfeature vector space size", FVS.shape)
235
+ return FVS
236
+
237
+
238
+ # assumes descriptors is already flattened
239
+ # returns descriptors after moving them into the PCA vector space
240
+ def descriptors_to_pca(descriptors, pca_target_dim, window_size, shape):
241
+ """
242
+ Applies Principal Component Analysis (PCA) to a set of descriptors.
243
+
244
+ Args:
245
+ descriptors (list): List of descriptors.
246
+ pca_target_dim (int): Target dimensionality for PCA.
247
+ window_size (int): Size of the sliding window.
248
+ shape (tuple): Shape of the descriptors.
249
+
250
+ Returns:
251
+ list: Feature vector set after applying PCA.
252
+ """
253
+ vector_set, mean_vec = find_vector_set(descriptors, window_size, shape)
254
+ pca = PCA(pca_target_dim)
255
+ pca.fit(vector_set)
256
+ EVS = pca.components_
257
+ mean_vec = np.dot(mean_vec, EVS.transpose())
258
+ FVS = find_FVS(descriptors, EVS.transpose(), mean_vec)
259
+ return FVS
260
+
261
+
262
+ def get_descriptors(
263
+ images,
264
+ window_size,
265
+ pca_dim_gray,
266
+ pca_dim_rgb,
267
+ debug=False,
268
+ output_directory=None,
269
+ ):
270
+ """
271
+ Compute descriptors for input images using sliding window technique and PCA.
272
+
273
+ Args:
274
+ images (tuple): A tuple containing the input image and reference image.
275
+ window_size (int): The size of the sliding window.
276
+ pca_dim_gray (int): The number of dimensions to keep for grayscale PCA.
277
+ pca_dim_rgb (int): The number of dimensions to keep for RGB PCA.
278
+ debug (bool, optional): Whether to enable debug mode. Defaults to False.
279
+ output_directory (str, optional): The directory to save debug images. Required if debug is True.
280
+
281
+ Returns:
282
+ numpy.ndarray: The computed descriptors.
283
+
284
+ Raises:
285
+ AssertionError: If debug is True but output_directory is not provided.
286
+ """
287
+ input_image, reference_image = images
288
+
289
+ diff_image_gray = cv2.cvtColor(
290
+ cv2.absdiff(input_image, reference_image), cv2.COLOR_BGR2GRAY
291
+ )
292
+
293
+ if debug:
294
+ assert output_directory is not None, "Output directory must be provided"
295
+ cv2.imwrite(os.path.join(output_directory, "diff.jpg"), diff_image_gray)
296
+
297
+ # Padding for windowing
298
+ padded_diff_gray = np.pad(
299
+ diff_image_gray,
300
+ ((window_size // 2, window_size // 2), (window_size // 2, window_size // 2)),
301
+ mode="constant",
302
+ )
303
+
304
+ # Sliding window for gray
305
+ shape = (input_image.shape[0], input_image.shape[1], window_size, window_size)
306
+ strides = padded_diff_gray.strides * 2
307
+ windows_gray = np.lib.stride_tricks.as_strided(
308
+ padded_diff_gray, shape=shape, strides=strides
309
+ )
310
+ descriptors_gray_diff = windows_gray.reshape(-1, window_size * window_size)
311
+
312
+ # 3-channel RGB differences
313
+ diff_image_r = cv2.absdiff(input_image[:, :, 0], reference_image[:, :, 0])
314
+ diff_image_g = cv2.absdiff(input_image[:, :, 1], reference_image[:, :, 1])
315
+ diff_image_b = cv2.absdiff(input_image[:, :, 2], reference_image[:, :, 2])
316
+
317
+ if debug:
318
+ assert output_directory is not None, "Output directory must be provided"
319
+ cv2.imwrite(
320
+ os.path.join(output_directory, "final_diff.jpg"),
321
+ cv2.absdiff(input_image, reference_image),
322
+ )
323
+ cv2.imwrite(os.path.join(output_directory, "final_diff_r.jpg"), diff_image_r)
324
+ cv2.imwrite(os.path.join(output_directory, "final_diff_g.jpg"), diff_image_g)
325
+ cv2.imwrite(os.path.join(output_directory, "final_diff_b.jpg"), diff_image_b)
326
+
327
+ # Padding for windowing RGB
328
+ padded_diff_r = np.pad(
329
+ diff_image_r,
330
+ ((window_size // 2, window_size // 2), (window_size // 2, window_size // 2)),
331
+ mode="constant",
332
+ )
333
+ padded_diff_g = np.pad(
334
+ diff_image_g,
335
+ ((window_size // 2, window_size // 2), (window_size // 2, window_size // 2)),
336
+ mode="constant",
337
+ )
338
+ padded_diff_b = np.pad(
339
+ diff_image_b,
340
+ ((window_size // 2, window_size // 2), (window_size // 2, window_size // 2)),
341
+ mode="constant",
342
+ )
343
+
344
+ # Sliding window for RGB
345
+ windows_r = np.lib.stride_tricks.as_strided(
346
+ padded_diff_r, shape=shape, strides=strides
347
+ )
348
+ windows_g = np.lib.stride_tricks.as_strided(
349
+ padded_diff_g, shape=shape, strides=strides
350
+ )
351
+ windows_b = np.lib.stride_tricks.as_strided(
352
+ padded_diff_b, shape=shape, strides=strides
353
+ )
354
+
355
+ descriptors_rgb_diff = np.concatenate(
356
+ [
357
+ windows_r.reshape(-1, window_size * window_size),
358
+ windows_g.reshape(-1, window_size * window_size),
359
+ windows_b.reshape(-1, window_size * window_size),
360
+ ],
361
+ axis=1,
362
+ )
363
+
364
+ # PCA on descriptors
365
+ shape = input_image.shape[::-1][1:] # shape = (height, width)
366
+ descriptors_gray_diff = descriptors_to_pca(
367
+ descriptors_gray_diff, pca_dim_gray, window_size, shape
368
+ )
369
+ descriptors_rgb_diff = descriptors_to_pca(
370
+ descriptors_rgb_diff, pca_dim_rgb, window_size, shape
371
+ )
372
+
373
+ # Concatenate grayscale and RGB PCA results
374
+ descriptors = np.concatenate((descriptors_gray_diff, descriptors_rgb_diff), axis=-1)
375
+
376
+ return descriptors
377
+
378
+
379
+ def k_means_clustering(FVS, components, image_shape):
380
+ """
381
+ Perform K-means clustering on the given feature vectors.
382
+
383
+ Args:
384
+ FVS (array-like): The feature vectors to be clustered.
385
+ components (int): The number of clusters (components) to create.
386
+ image_shape (tuple): The size of the images used to reshape the change map.
387
+
388
+ Returns:
389
+ array-like: The change map obtained from the K-means clustering.
390
+
391
+ """
392
+ kmeans = KMeans(components, verbose=0)
393
+ kmeans.fit(FVS)
394
+ flatten_change_map = kmeans.predict(FVS)
395
+ change_map = np.reshape(flatten_change_map, (image_shape[0], image_shape[1]))
396
+ return change_map
397
+
398
+
399
+ def clustering_to_mse_values(change_map, input_image, reference_image, n):
400
+ """
401
+ Compute the normalized mean squared error (MSE) values for each cluster in a change map.
402
+
403
+ Args:
404
+ change_map (numpy.ndarray): Array representing the cluster labels for each pixel in the change map.
405
+ input_image (numpy.ndarray): Array representing the input image.
406
+ reference_image (numpy.ndarray): Array representing the reference image.
407
+ n (int): Number of clusters.
408
+
409
+ Returns:
410
+ list: Normalized MSE values for each cluster.
411
+
412
+ """
413
+
414
+ # Ensure the images are in integer format for calculations
415
+ input_image = input_image.astype(int)
416
+ reference_image = reference_image.astype(int)
417
+
418
+ # Compute the squared differences
419
+ squared_diff = np.mean((input_image - reference_image) ** 2, axis=-1)
420
+
421
+ # Initialize arrays to store MSE and size for each cluster
422
+ mse = np.zeros(n, dtype=float)
423
+ size = np.zeros(n, dtype=int)
424
+
425
+ # Compute the MSE and size for each cluster
426
+ for k in range(n):
427
+ mask = change_map == k
428
+ size[k] = np.sum(mask)
429
+ if size[k] > 0:
430
+ mse[k] = np.sum(squared_diff[mask])
431
+
432
+ # Normalize MSE values by the number of pixels and the maximum possible MSE (255^2)
433
+ normalized_mse = (mse / size) / (255**2)
434
+
435
+ return normalized_mse.tolist()
436
+
437
+
438
+ def compute_change_map(
439
+ images,
440
+ window_size,
441
+ clusters,
442
+ pca_dim_gray,
443
+ pca_dim_rgb,
444
+ debug=False,
445
+ output_directory=None,
446
+ ):
447
+ """
448
+ Compute the change map and mean squared error (MSE) array for a pair of input and reference images.
449
+
450
+ Args:
451
+ images (tuple): A tuple containing the input and reference images.
452
+ window_size (int): The size of the sliding window for feature extraction.
453
+ clusters (int): The number of clusters for k-means clustering.
454
+ pca_dim_gray (int): The number of dimensions to reduce to for grayscale images.
455
+ pca_dim_rgb (int): The number of dimensions to reduce to for RGB images.
456
+ debug (bool, optional): Whether to enable debug mode. Defaults to False.
457
+ output_directory (str, optional): The directory to save the output files. Required if debug mode is enabled.
458
+
459
+ Returns:
460
+ tuple: A tuple containing the change map and MSE array.
461
+
462
+ Raises:
463
+ AssertionError: If debug mode is enabled but output_directory is not provided.
464
+
465
+ """
466
+ input_image, reference_image = images
467
+ descriptors = get_descriptors(
468
+ images,
469
+ window_size,
470
+ pca_dim_gray,
471
+ pca_dim_rgb,
472
+ debug=debug,
473
+ output_directory=output_directory,
474
+ )
475
+ # Now we are ready for clustering!
476
+ change_map = k_means_clustering(descriptors, clusters, input_image.shape)
477
+ mse_array = clustering_to_mse_values(
478
+ change_map, input_image, reference_image, clusters
479
+ )
480
+
481
+ colormap = mcolors.LinearSegmentedColormap.from_list(
482
+ "custom_jet", plt.cm.jet(np.linspace(0, 1, clusters))
483
+ )
484
+ colors_array = (
485
+ colormap(np.linspace(0, 1, clusters))[:, :3] * 255
486
+ ) # Convert to RGB values
487
+
488
+ palette = sns.color_palette("Paired", clusters)
489
+ palette = np.array(palette) * 255 # Convert to RGB values
490
+
491
+ # Optimized loop
492
+ change_map_flat = change_map.ravel()
493
+ colored_change_map_flat = (
494
+ colors_array[change_map_flat]
495
+ .reshape(change_map.shape[0], change_map.shape[1], 3)
496
+ .astype(np.uint8)
497
+ )
498
+ palette_colored_change_map_flat = (
499
+ palette[change_map_flat]
500
+ .reshape(change_map.shape[0], change_map.shape[1], 3)
501
+ .astype(np.uint8)
502
+ )
503
+
504
+ if debug:
505
+ assert output_directory is not None, "Output directory must be provided"
506
+ cv2.imwrite(
507
+ os.path.join(
508
+ output_directory,
509
+ f"window_size_{window_size}_pca_dim_gray{pca_dim_gray}_pca_dim_rgb{pca_dim_rgb}_clusters_{clusters}.jpg",
510
+ ),
511
+ colored_change_map_flat,
512
+ )
513
+ cv2.imwrite(
514
+ os.path.join(
515
+ output_directory,
516
+ f"PALETTE_window_size_{window_size}_pca_dim_gray{pca_dim_gray}_pca_dim_rgb{pca_dim_rgb}_clusters_{clusters}.jpg",
517
+ ),
518
+ palette_colored_change_map_flat,
519
+ )
520
+
521
+ if debug:
522
+ assert output_directory is not None, "Output directory must be provided"
523
+ # Saving Output for later evaluation
524
+ np.savetxt(
525
+ os.path.join(output_directory, "clustering_data.csv"),
526
+ change_map,
527
+ delimiter=",",
528
+ )
529
+ return change_map, mse_array
530
+
531
+
532
+ # selects the classes to be shown to the user as 'changes'.
533
+ # this selection is done by an MSE heuristic using DBSCAN clustering, to seperate the highest mse-valued classes from the others.
534
+ # the eps density parameter of DBSCAN might differ from system to system
535
+ def find_group_of_accepted_classes_DBSCAN(
536
+ MSE_array, debug=False, output_directory=None
537
+ ):
538
+ """
539
+ Finds the group of accepted classes using the DBSCAN algorithm.
540
+
541
+ Parameters:
542
+ - MSE_array (list): A list of mean squared error values.
543
+ - debug (bool): Flag indicating whether to enable debug mode or not. Default is False.
544
+ - output_directory (str): The directory where the output files will be saved. Default is None.
545
+
546
+ Returns:
547
+ - accepted_classes (list): A list of indices of the accepted classes.
548
+ """
549
+
550
+ clustering = DBSCAN(eps=0.02, min_samples=1).fit(np.array(MSE_array).reshape(-1, 1))
551
+ number_of_clusters = len(set(clustering.labels_))
552
+ if number_of_clusters == 1:
553
+ print("No significant changes are detected.")
554
+ exit(0)
555
+
556
+ # print(clustering.labels_)
557
+ classes = [[] for _ in range(number_of_clusters)]
558
+ centers = np.zeros(number_of_clusters)
559
+
560
+ np.add.at(centers, clustering.labels_, MSE_array)
561
+
562
+ for i in range(len(MSE_array)):
563
+ classes[clustering.labels_[i]].append(i)
564
+
565
+ centers /= np.array([len(c) for c in classes])
566
+
567
+ min_class = np.argmin(centers)
568
+ accepted_classes = np.where(clustering.labels_ != min_class)[0]
569
+
570
+ if debug:
571
+ assert output_directory is not None, "Output directory must be provided"
572
+ plt.figure()
573
+ plt.xlabel("Index")
574
+ plt.ylabel("MSE")
575
+ plt.scatter(range(len(MSE_array)), MSE_array, c="red")
576
+ plt.scatter(
577
+ accepted_classes[:],
578
+ np.array(MSE_array)[np.array(accepted_classes)],
579
+ c="blue",
580
+ )
581
+ plt.title("K Mean Classification")
582
+
583
+ plt.savefig(os.path.join(output_directory, "mse.png"))
584
+
585
+ # save output for later evaluation
586
+ np.savetxt(
587
+ os.path.join(output_directory, "accepted_classes.csv"),
588
+ accepted_classes,
589
+ delimiter=",",
590
+ )
591
+ return [accepted_classes]
592
+
593
+
594
+ def draw_combination_on_transparent_input_image(
595
+ classes_mse, clustering, combination, transparent_input_image
596
+ ):
597
+ """
598
+ Draws a combination of classes on a transparent input image based on their mean squared error (MSE) order.
599
+
600
+ Args:
601
+ classes_mse (numpy.ndarray): Array of mean squared errors for each class.
602
+ clustering (dict): Dictionary containing the clustering information for each class.
603
+ combination (list): List of classes to be drawn on the image.
604
+ transparent_input_image (numpy.ndarray): Transparent input image.
605
+
606
+ Returns:
607
+ numpy.ndarray: Transparent input image with the specified combination of classes drawn on it.
608
+ """
609
+
610
+ # HEAT MAP ACCORDING TO MSE ORDER
611
+ sorted_indexes = np.argsort(classes_mse)
612
+ for class_ in combination:
613
+ index = np.argwhere(sorted_indexes == class_).flatten()[0]
614
+ c = plt.cm.jet(float(index) / (len(classes_mse) - 1))
615
+ for [i, j] in clustering[class_]:
616
+ transparent_input_image[i, j] = (
617
+ c[2] * 255,
618
+ c[1] * 255,
619
+ c[0] * 255,
620
+ 255,
621
+ ) # BGR
622
+ return transparent_input_image
623
+
624
+
625
+ def detect_changes(
626
+ images,
627
+ output_alpha,
628
+ window_size,
629
+ clusters,
630
+ pca_dim_gray,
631
+ pca_dim_rgb,
632
+ debug=False,
633
+ output_directory=None,
634
+ ):
635
+ """
636
+ Detects changes between two images using a combination of clustering and image processing techniques.
637
+
638
+ Args:
639
+ images (tuple): A tuple containing two input images.
640
+ output_alpha (int): The alpha value for the output image.
641
+ window_size (int): The size of the sliding window used for computing change map.
642
+ clusters (int): The number of clusters used for clustering pixels.
643
+ pca_dim_gray (int): The number of dimensions to reduce the grayscale image to using PCA.
644
+ pca_dim_rgb (int): The number of dimensions to reduce the RGB image to using PCA.
645
+ debug (bool, optional): Whether to enable debug mode. Defaults to False.
646
+ output_directory (str, optional): The output directory for saving intermediate results. Defaults to None.
647
+
648
+ Returns:
649
+ numpy.ndarray: The resulting image with detected changes.
650
+
651
+ """
652
+ start_time = time.time()
653
+ input_image, _ = images
654
+ clustering_map, mse_array = compute_change_map(
655
+ images,
656
+ window_size=window_size,
657
+ clusters=clusters,
658
+ pca_dim_gray=pca_dim_gray,
659
+ pca_dim_rgb=pca_dim_rgb,
660
+ debug=debug,
661
+ output_directory=output_directory,
662
+ )
663
+
664
+ clustering = [np.empty((0, 2), dtype=int) for _ in range(clusters)]
665
+
666
+ # Get the indices of each element in the clustering_map
667
+ indices = np.indices(clustering_map.shape).transpose(1, 2, 0).reshape(-1, 2)
668
+ flattened_map = clustering_map.flatten()
669
+
670
+ for cluster_idx in range(clusters):
671
+ clustering[cluster_idx] = indices[flattened_map == cluster_idx]
672
+
673
+ b_channel, g_channel, r_channel = cv2.split(input_image)
674
+ alpha_channel = np.ones(b_channel.shape, dtype=b_channel.dtype) * 255
675
+ alpha_channel[:, :] = output_alpha
676
+ groups = find_group_of_accepted_classes_DBSCAN(mse_array, output_directory)
677
+
678
+ for group in groups:
679
+ transparent_input_image = cv2.merge(
680
+ (b_channel, g_channel, r_channel, alpha_channel)
681
+ )
682
+ result = draw_combination_on_transparent_input_image(
683
+ mse_array, clustering, group, transparent_input_image
684
+ )
685
+
686
+ print("--- Detect Changes time - %s seconds ---" % (time.time() - start_time))
687
+ return result
688
+
689
+
690
+ def pipeline(
691
+ images,
692
+ resize_factor=1.0,
693
+ output_alpha=50,
694
+ window_size=5,
695
+ clusters=16,
696
+ pca_dim_gray=3,
697
+ pca_dim_rgb=9,
698
+ debug=False,
699
+ output_directory=None,
700
+ ):
701
+ """
702
+ Applies a pipeline of image processing steps to detect changes in a sequence of images.
703
+
704
+ Args:
705
+ images (tuple): A list of input images.
706
+ resize_factor (float, optional): The factor by which to resize the images. Defaults to 1.0.
707
+ output_alpha (int, optional): The alpha value for the output images. Defaults to 50.
708
+ window_size (int, optional): The size of the sliding window for change detection. Defaults to 5.
709
+ clusters (int, optional): The number of clusters for color quantization. Defaults to 16.
710
+ pca_dim_gray (int, optional): The number of dimensions to keep for grayscale PCA. Defaults to 3.
711
+ pca_dim_rgb (int, optional): The number of dimensions to keep for RGB PCA. Defaults to 9.
712
+ debug (bool, optional): Whether to enable debug mode. Defaults to False.
713
+ output_directory (str, optional): The directory to save the output images. Defaults to None.
714
+
715
+ Returns:
716
+ numpy.ndarray: The resulting image with detected changes.
717
+ """
718
+ if output_directory:
719
+ os.makedirs(output_directory, exist_ok=True)
720
+
721
+ preprocessed_images = preprocess_images(
722
+ images,
723
+ resize_factor=resize_factor,
724
+ debug=debug,
725
+ output_directory=output_directory,
726
+ )
727
+ result = detect_changes(
728
+ preprocessed_images,
729
+ output_alpha=output_alpha,
730
+ window_size=window_size,
731
+ clusters=clusters,
732
+ pca_dim_gray=pca_dim_gray,
733
+ pca_dim_rgb=pca_dim_rgb,
734
+ debug=debug,
735
+ output_directory=output_directory,
736
+ )
737
+
738
+ return result
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ numpy==1.26.4
2
+ opencv-python-headless==4.10.0.82
3
+ scikit-learn==1.5.0
4
+ scikit-image==0.23.2
5
+ seaborn==0.13.2
6
+ gradio==4.36.0