Semapp 1.0.5__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.
- semapp/Layout/__init__.py +26 -0
- semapp/Layout/create_button.py +1248 -0
- semapp/Layout/main_window_att.py +54 -0
- semapp/Layout/settings.py +170 -0
- semapp/Layout/styles.py +152 -0
- semapp/Layout/toast.py +157 -0
- semapp/Plot/__init__.py +8 -0
- semapp/Plot/frame_attributes.py +690 -0
- semapp/Plot/overview_window.py +355 -0
- semapp/Plot/styles.py +55 -0
- semapp/Plot/utils.py +295 -0
- semapp/Processing/__init__.py +4 -0
- semapp/Processing/detection.py +513 -0
- semapp/Processing/klarf_reader.py +461 -0
- semapp/Processing/processing.py +686 -0
- semapp/Processing/rename_tif.py +498 -0
- semapp/Processing/split_tif.py +323 -0
- semapp/Processing/threshold.py +777 -0
- semapp/__init__.py +10 -0
- semapp/asset/icon.png +0 -0
- semapp/main.py +103 -0
- semapp-1.0.5.dist-info/METADATA +300 -0
- semapp-1.0.5.dist-info/RECORD +27 -0
- semapp-1.0.5.dist-info/WHEEL +5 -0
- semapp-1.0.5.dist-info/entry_points.txt +2 -0
- semapp-1.0.5.dist-info/licenses/LICENSE +674 -0
- semapp-1.0.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A class to manage and display frames in the UI, providing functionality
|
|
3
|
+
for plotting and saving combined screenshots of images and plots.
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import numpy as np
|
|
7
|
+
import glob
|
|
8
|
+
import re
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import cv2
|
|
11
|
+
from PIL import Image
|
|
12
|
+
from semapp.Processing.threshold import SEMThresholdProcessor
|
|
13
|
+
from semapp.Processing.klarf_reader import extract_positions
|
|
14
|
+
from PyQt5.QtWidgets import QFrame, QGroupBox, QWidget, QVBoxLayout, QPushButton, \
|
|
15
|
+
QGridLayout, QLabel, QFileDialog, QProgressDialog, QMessageBox
|
|
16
|
+
from PyQt5.QtGui import QImage, QPixmap
|
|
17
|
+
from PyQt5.QtCore import Qt
|
|
18
|
+
from matplotlib.figure import Figure
|
|
19
|
+
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
20
|
+
import matplotlib.pyplot as plt
|
|
21
|
+
from semapp.Plot.utils import create_savebutton
|
|
22
|
+
from semapp.Plot.styles import OPEN_BUTTON_STYLE, MESSAGE_BOX_STYLE, FRAME_STYLE
|
|
23
|
+
from semapp.Plot.overview_window import OverviewWindow
|
|
24
|
+
|
|
25
|
+
# Constants
|
|
26
|
+
FRAME_SIZE = 600
|
|
27
|
+
CANVAS_SIZE = 600
|
|
28
|
+
radius = 10
|
|
29
|
+
|
|
30
|
+
class PlotFrame(QWidget):
|
|
31
|
+
"""
|
|
32
|
+
A class to manage and display frames in the UI.
|
|
33
|
+
|
|
34
|
+
Provides functionality for:
|
|
35
|
+
- Opening and displaying TIFF images
|
|
36
|
+
- Plotting coordinate mappings
|
|
37
|
+
- Interactive defect selection
|
|
38
|
+
- Overview window with image thumbnails
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
layout (QGridLayout): Main layout for the frame
|
|
42
|
+
button_frame (ButtonFrame): Reference to button controls
|
|
43
|
+
frame_left (QFrame): Left frame for image display
|
|
44
|
+
frame_right (QFrame): Right frame for plot display
|
|
45
|
+
coordinates (pd.DataFrame): DataFrame with defect coordinates
|
|
46
|
+
image_list (list): List of PIL Image objects
|
|
47
|
+
current_tiff_path (str): Path to currently loaded TIFF file
|
|
48
|
+
is_complus4t_mode (bool): Flag indicating COMPLUS4T mode
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, layout, button_frame):
|
|
52
|
+
"""
|
|
53
|
+
Initialize the PlotFrame class.
|
|
54
|
+
|
|
55
|
+
Sets up UI components, initializes variables, and creates
|
|
56
|
+
the left and right frames for image and plot display.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
layout (QGridLayout): The layout to which the frames will be added.
|
|
60
|
+
button_frame (ButtonFrame): The button frame containing control elements.
|
|
61
|
+
"""
|
|
62
|
+
super().__init__()
|
|
63
|
+
self.layout = layout
|
|
64
|
+
self.button_frame = button_frame
|
|
65
|
+
|
|
66
|
+
# Initialize state
|
|
67
|
+
self.coordinates = None
|
|
68
|
+
self.image_list = []
|
|
69
|
+
self.current_index = 0
|
|
70
|
+
self.canvas_connection_id = None
|
|
71
|
+
self.selected_wafer = None
|
|
72
|
+
self.radius = None
|
|
73
|
+
self.is_complus4t_mode = False # COMPLUS4T mode detected
|
|
74
|
+
self.show_thresholded = True # Toggle for threshold display
|
|
75
|
+
self.current_tiff_path = None # Store current TIFF file path
|
|
76
|
+
self.last_clicked_position = None # Store last clicked position (x, y)
|
|
77
|
+
self.overview_window = None # Store reference to overview window to prevent garbage collection
|
|
78
|
+
|
|
79
|
+
self._setup_frames()
|
|
80
|
+
self._setup_plot()
|
|
81
|
+
self._setup_controls()
|
|
82
|
+
|
|
83
|
+
def _setup_frames(self):
|
|
84
|
+
"""Initialize left and right display frames."""
|
|
85
|
+
# Left frame for images
|
|
86
|
+
self.frame_left = self._create_frame()
|
|
87
|
+
self.frame_left_layout = QVBoxLayout()
|
|
88
|
+
self.frame_left.setLayout(self.frame_left_layout)
|
|
89
|
+
|
|
90
|
+
# Right frame for plots
|
|
91
|
+
self.frame_right = self._create_frame()
|
|
92
|
+
self.frame_right_layout = QGridLayout()
|
|
93
|
+
self.frame_right.setLayout(self.frame_right_layout)
|
|
94
|
+
|
|
95
|
+
# Add frames to main layout (shifted down by 1 row)
|
|
96
|
+
self.layout.addWidget(self.frame_left, 3, 0, 1, 3)
|
|
97
|
+
self.layout.addWidget(self.frame_right, 3, 3, 1, 3)
|
|
98
|
+
|
|
99
|
+
def _create_frame(self):
|
|
100
|
+
"""Create a styled frame with fixed size."""
|
|
101
|
+
frame = QFrame()
|
|
102
|
+
frame.setFrameShape(QFrame.StyledPanel)
|
|
103
|
+
frame.setStyleSheet(FRAME_STYLE)
|
|
104
|
+
frame.setFixedSize(FRAME_SIZE+100, FRAME_SIZE)
|
|
105
|
+
return frame
|
|
106
|
+
|
|
107
|
+
def _setup_plot(self):
|
|
108
|
+
"""Initialize matplotlib figure and canvas."""
|
|
109
|
+
self.figure = Figure(figsize=(5, 5))
|
|
110
|
+
self.ax = self.figure.add_subplot(111)
|
|
111
|
+
self.canvas = FigureCanvas(self.figure)
|
|
112
|
+
self.frame_right_layout.addWidget(self.canvas)
|
|
113
|
+
|
|
114
|
+
# Initialize image display
|
|
115
|
+
self.image_label = QLabel(self)
|
|
116
|
+
self.image_label.setAlignment(Qt.AlignCenter)
|
|
117
|
+
self.frame_left_layout.addWidget(self.image_label)
|
|
118
|
+
|
|
119
|
+
def _setup_controls(self):
|
|
120
|
+
"""Set up control buttons."""
|
|
121
|
+
create_savebutton(self.layout, self.frame_left, self.frame_right, self.button_frame)
|
|
122
|
+
|
|
123
|
+
open_button = QPushButton('Open TIFF', self)
|
|
124
|
+
open_button.setStyleSheet(OPEN_BUTTON_STYLE)
|
|
125
|
+
open_button.clicked.connect(self.open_tiff)
|
|
126
|
+
self.layout.addWidget(open_button, 1, 5, 1, 1) # Takes 1 row
|
|
127
|
+
|
|
128
|
+
overview_button = QPushButton('Overview', self)
|
|
129
|
+
overview_button.setStyleSheet(OPEN_BUTTON_STYLE)
|
|
130
|
+
overview_button.clicked.connect(self.show_overview)
|
|
131
|
+
self.layout.addWidget(overview_button, 2, 5, 1, 1) # Below Open TIFF
|
|
132
|
+
|
|
133
|
+
def extract_positions(self, filepath, wafer_id=None):
|
|
134
|
+
"""
|
|
135
|
+
Extract defect positions from KLARF file.
|
|
136
|
+
Wrapper method that calls the klarf_reader module.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
filepath: Path to the KLARF (.001) file
|
|
140
|
+
wafer_id: Specific wafer ID to extract (for COMPLUS4T files with multiple wafers)
|
|
141
|
+
If None, extracts all defects (normal mode)
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
pd.DataFrame: DataFrame with columns ["defect_id", "X", "Y", "defect_size"]
|
|
145
|
+
"""
|
|
146
|
+
# Call the klarf_reader function
|
|
147
|
+
self.coordinates = extract_positions(filepath, wafer_id=wafer_id)
|
|
148
|
+
return self.coordinates
|
|
149
|
+
|
|
150
|
+
def open_tiff(self):
|
|
151
|
+
"""Handle TIFF file opening and display."""
|
|
152
|
+
self.selected_wafer = self.button_frame.get_selected_option()
|
|
153
|
+
|
|
154
|
+
if not all([self.selected_wafer]):
|
|
155
|
+
self._reset_display()
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Check if COMPLUS4T mode is active
|
|
159
|
+
dirname = self.button_frame.folder_var_changed()
|
|
160
|
+
is_complus4t = self._check_complus4t_mode(dirname)
|
|
161
|
+
self.is_complus4t_mode = is_complus4t # Store mode for later use
|
|
162
|
+
|
|
163
|
+
if is_complus4t:
|
|
164
|
+
# COMPLUS4T mode: .001 and .tiff files in parent directory
|
|
165
|
+
folder_path = dirname
|
|
166
|
+
|
|
167
|
+
# Find the .001 file with the selected wafer ID in parent directory
|
|
168
|
+
matching_files = glob.glob(os.path.join(dirname, '*.001'))
|
|
169
|
+
recipe_path = None
|
|
170
|
+
|
|
171
|
+
for file_path in matching_files:
|
|
172
|
+
if self._is_wafer_in_klarf(file_path, self.selected_wafer):
|
|
173
|
+
recipe_path = file_path
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
# Find the only .tiff file in the parent directory
|
|
177
|
+
tiff_files = glob.glob(os.path.join(dirname, '*.tiff'))
|
|
178
|
+
if not tiff_files:
|
|
179
|
+
tiff_files = glob.glob(os.path.join(dirname, '*.tif'))
|
|
180
|
+
|
|
181
|
+
if tiff_files:
|
|
182
|
+
tiff_path = tiff_files[0]
|
|
183
|
+
else:
|
|
184
|
+
self._reset_display()
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
# Extract positions for the specific wafer
|
|
188
|
+
self.coordinates = self.extract_positions(recipe_path, wafer_id=self.selected_wafer)
|
|
189
|
+
else:
|
|
190
|
+
# Normal mode: subfolders
|
|
191
|
+
folder_path = os.path.join(dirname, str(self.selected_wafer))
|
|
192
|
+
|
|
193
|
+
# Find the first .001 file in the selected folder
|
|
194
|
+
matching_files = glob.glob(os.path.join(folder_path, '*.001'))
|
|
195
|
+
|
|
196
|
+
# Sort the files to ensure consistent ordering
|
|
197
|
+
if matching_files:
|
|
198
|
+
recipe_path = matching_files[0]
|
|
199
|
+
else:
|
|
200
|
+
recipe_path = None
|
|
201
|
+
|
|
202
|
+
tiff_path = os.path.join(folder_path, "data.tif")
|
|
203
|
+
|
|
204
|
+
if not os.path.isfile(tiff_path):
|
|
205
|
+
self._reset_display()
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# Extract all positions (normal mode)
|
|
209
|
+
self.coordinates = self.extract_positions(recipe_path)
|
|
210
|
+
|
|
211
|
+
self._load_tiff(tiff_path)
|
|
212
|
+
self._update_plot()
|
|
213
|
+
|
|
214
|
+
# Set reference to plot_frame in button_frame for slider updates
|
|
215
|
+
self.button_frame.plot_frame = self
|
|
216
|
+
|
|
217
|
+
msg = QMessageBox()
|
|
218
|
+
msg.setIcon(QMessageBox.Information)
|
|
219
|
+
msg.setText(f"Wafer {self.selected_wafer} opened successfully")
|
|
220
|
+
msg.setWindowTitle("Wafer Opened")
|
|
221
|
+
msg.setStyleSheet(MESSAGE_BOX_STYLE)
|
|
222
|
+
msg.exec_()
|
|
223
|
+
|
|
224
|
+
def _check_complus4t_mode(self, dirname):
|
|
225
|
+
"""Check if we are in COMPLUS4T mode (.001 files with COMPLUS4T in parent directory)."""
|
|
226
|
+
if not dirname or not os.path.exists(dirname):
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
# Check for .001 files with COMPLUS4T in the parent directory
|
|
230
|
+
matching_files = glob.glob(os.path.join(dirname, '*.001'))
|
|
231
|
+
for file_path in matching_files:
|
|
232
|
+
try:
|
|
233
|
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
234
|
+
content = f.read()
|
|
235
|
+
if 'COMPLUS4T' in content:
|
|
236
|
+
return True
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
def _is_wafer_in_klarf(self, file_path, wafer_id):
|
|
243
|
+
"""Check if a specific wafer ID is in the KLARF file."""
|
|
244
|
+
try:
|
|
245
|
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
246
|
+
content = f.read()
|
|
247
|
+
pattern = r'WaferID\s+"@' + str(wafer_id) + r'"'
|
|
248
|
+
return re.search(pattern, content) is not None
|
|
249
|
+
except Exception:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
def _reset_display(self):
|
|
253
|
+
"""
|
|
254
|
+
Resets the display by clearing the figure and reinitializing the subplot.
|
|
255
|
+
Also clears the frame_left_layout to remove any existing widgets.
|
|
256
|
+
"""
|
|
257
|
+
# Clear all widgets from the left frame layout
|
|
258
|
+
while self.frame_left_layout.count():
|
|
259
|
+
item = self.frame_left_layout.takeAt(0)
|
|
260
|
+
widget = item.widget()
|
|
261
|
+
if widget is not None:
|
|
262
|
+
widget.deleteLater() # Properly delete the widget
|
|
263
|
+
|
|
264
|
+
# Recreate the image label in the left frame
|
|
265
|
+
self.image_label = QLabel(self)
|
|
266
|
+
self.image_label.setAlignment(Qt.AlignCenter)
|
|
267
|
+
self.frame_left_layout.addWidget(self.image_label)
|
|
268
|
+
|
|
269
|
+
# Clear the figure associated with the canvas
|
|
270
|
+
self.figure.clear()
|
|
271
|
+
self.ax = self.figure.add_subplot(111) # Create a new subplot
|
|
272
|
+
self.plot_mapping_tpl(self.ax) # Plot the default template
|
|
273
|
+
|
|
274
|
+
# Disconnect any existing signal connection
|
|
275
|
+
if self.canvas_connection_id is not None:
|
|
276
|
+
self.canvas.mpl_disconnect(self.canvas_connection_id)
|
|
277
|
+
self.canvas_connection_id = None
|
|
278
|
+
|
|
279
|
+
self.canvas.draw() # Redraw the updated canvas
|
|
280
|
+
|
|
281
|
+
def _update_plot(self):
|
|
282
|
+
"""
|
|
283
|
+
Updates the plot with the current wafer mapping.
|
|
284
|
+
Ensures the plot is clean before adding new data.
|
|
285
|
+
"""
|
|
286
|
+
if hasattr(self, 'ax') and self.ax:
|
|
287
|
+
self.ax.clear() # Clear the existing plot
|
|
288
|
+
else:
|
|
289
|
+
self.ax = self.figure.add_subplot(111) # Create new axes
|
|
290
|
+
|
|
291
|
+
self.plot_mapping_tpl(self.ax) # Plot wafer mapping
|
|
292
|
+
|
|
293
|
+
# Ensure only one connection to the button press event
|
|
294
|
+
if self.canvas_connection_id is not None:
|
|
295
|
+
self.canvas.mpl_disconnect(self.canvas_connection_id)
|
|
296
|
+
|
|
297
|
+
self.canvas_connection_id = self.canvas.mpl_connect(
|
|
298
|
+
'button_press_event', self.on_click)
|
|
299
|
+
self.canvas.draw()
|
|
300
|
+
|
|
301
|
+
def show_image(self):
|
|
302
|
+
"""
|
|
303
|
+
Displays the current image from the image list in the QLabel with threshold applied.
|
|
304
|
+
"""
|
|
305
|
+
if self.image_list:
|
|
306
|
+
# Check if current_index is valid (for KRONOS/COMPLUS4T, there's no current_index)
|
|
307
|
+
if not hasattr(self, 'current_index') or self.current_index is None:
|
|
308
|
+
# For KRONOS/COMPLUS4T mode, use first image
|
|
309
|
+
self.current_index = 0
|
|
310
|
+
|
|
311
|
+
# Ensure current_index is within bounds
|
|
312
|
+
if self.current_index >= len(self.image_list):
|
|
313
|
+
self.current_index = 0
|
|
314
|
+
|
|
315
|
+
print(f"Showing image {self.current_index} of {len(self.image_list)}")
|
|
316
|
+
pil_image = self.image_list[self.current_index]
|
|
317
|
+
print(f"Original image mode: {pil_image.mode}, size: {pil_image.size}")
|
|
318
|
+
|
|
319
|
+
# Get threshold value from button frame
|
|
320
|
+
threshold = 255 # Default threshold (matches slider default)
|
|
321
|
+
if self.button_frame and hasattr(self.button_frame, 'get_threshold_value'):
|
|
322
|
+
threshold = self.button_frame.get_threshold_value()
|
|
323
|
+
|
|
324
|
+
print(f"Using threshold: {threshold}")
|
|
325
|
+
|
|
326
|
+
# Apply threshold processing
|
|
327
|
+
processed_image = self._apply_threshold(pil_image, threshold)
|
|
328
|
+
print(f"Processed image mode: {processed_image.mode}, size: {processed_image.size}")
|
|
329
|
+
|
|
330
|
+
# Convert to RGBA for display
|
|
331
|
+
processed_image = processed_image.convert("RGBA")
|
|
332
|
+
data = processed_image.tobytes("raw", "RGBA")
|
|
333
|
+
qimage = QImage(data, processed_image.width, processed_image.height,
|
|
334
|
+
QImage.Format_RGBA8888)
|
|
335
|
+
pixmap = QPixmap.fromImage(qimage)
|
|
336
|
+
|
|
337
|
+
# Scale image to fit the frame while maintaining aspect ratio
|
|
338
|
+
frame_size = self.frame_left.size()
|
|
339
|
+
# Account for frame margins/padding (use ~95% of frame size)
|
|
340
|
+
max_width = int(frame_size.width() * 0.95)
|
|
341
|
+
max_height = int(frame_size.height() * 0.95)
|
|
342
|
+
|
|
343
|
+
# Scale pixmap to fit within frame bounds while keeping aspect ratio
|
|
344
|
+
scaled_pixmap = pixmap.scaled(max_width, max_height,
|
|
345
|
+
Qt.KeepAspectRatio,
|
|
346
|
+
Qt.SmoothTransformation)
|
|
347
|
+
|
|
348
|
+
self.image_label.setPixmap(scaled_pixmap)
|
|
349
|
+
print(f"Image displayed successfully (original: {pixmap.width()}x{pixmap.height()}, scaled: {scaled_pixmap.width()}x{scaled_pixmap.height()}, frame: {max_width}x{max_height})")
|
|
350
|
+
else:
|
|
351
|
+
print("No images in image_list")
|
|
352
|
+
|
|
353
|
+
def _apply_threshold(self, pil_image, threshold):
|
|
354
|
+
"""
|
|
355
|
+
Apply threshold processing to a PIL image using SEMThresholdProcessor.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
pil_image: PIL Image object
|
|
359
|
+
threshold: Threshold value (1-255)
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
PIL Image with threshold applied
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
# Debug: Print image info
|
|
366
|
+
print(f"Original image mode: {pil_image.mode}, size: {pil_image.size}")
|
|
367
|
+
print(f"Threshold: {threshold}")
|
|
368
|
+
|
|
369
|
+
# Convert PIL to numpy array
|
|
370
|
+
img_array = np.array(pil_image)
|
|
371
|
+
print(f"Image shape: {img_array.shape}, dtype: {img_array.dtype}")
|
|
372
|
+
print(f"Image min: {img_array.min()}, max: {img_array.max()}")
|
|
373
|
+
|
|
374
|
+
# Convert to grayscale if needed
|
|
375
|
+
if len(img_array.shape) == 3:
|
|
376
|
+
if img_array.shape[2] == 4: # RGBA
|
|
377
|
+
gray = cv2.cvtColor(img_array, cv2.COLOR_RGBA2GRAY)
|
|
378
|
+
elif img_array.shape[2] == 3: # RGB
|
|
379
|
+
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
|
|
380
|
+
else:
|
|
381
|
+
gray = img_array[:, :, 0]
|
|
382
|
+
else:
|
|
383
|
+
gray = img_array
|
|
384
|
+
|
|
385
|
+
print(f"Grayscale shape: {gray.shape}, min: {gray.min()}, max: {gray.max()}")
|
|
386
|
+
|
|
387
|
+
# Apply smoothing with kernel size 3
|
|
388
|
+
kernel_size = 3
|
|
389
|
+
image_smooth = cv2.GaussianBlur(gray, (kernel_size, kernel_size), 0)
|
|
390
|
+
|
|
391
|
+
# Apply thresholding
|
|
392
|
+
_, binary_image = cv2.threshold(image_smooth, threshold, 255, cv2.THRESH_BINARY)
|
|
393
|
+
|
|
394
|
+
print(f"Binary shape: {binary_image.shape}, min: {binary_image.min()}, max: {binary_image.max()}")
|
|
395
|
+
|
|
396
|
+
# Find contours (same as threshold.py)
|
|
397
|
+
contours, _ = cv2.findContours(binary_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
398
|
+
|
|
399
|
+
# Filter contours by size (same as threshold.py)
|
|
400
|
+
min_size = 2 # Default minimum particle size
|
|
401
|
+
if self.button_frame and hasattr(self.button_frame, 'get_min_size_value'):
|
|
402
|
+
min_size = self.button_frame.get_min_size_value()
|
|
403
|
+
|
|
404
|
+
detected_particles = []
|
|
405
|
+
for contour in contours:
|
|
406
|
+
area = cv2.contourArea(contour)
|
|
407
|
+
if area > min_size:
|
|
408
|
+
detected_particles.append({
|
|
409
|
+
'area': area,
|
|
410
|
+
'contour': contour
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
print(f"Found {len(detected_particles)} particles")
|
|
414
|
+
|
|
415
|
+
# Create visualization: show contours on original image (same as threshold.py)
|
|
416
|
+
original_with_contours = cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB)
|
|
417
|
+
|
|
418
|
+
# Draw contours on original image
|
|
419
|
+
for particle in detected_particles:
|
|
420
|
+
cv2.fillPoly(original_with_contours, [particle['contour']], (0, 255, 0)) # Green fill
|
|
421
|
+
|
|
422
|
+
print(f"Contour image shape: {original_with_contours.shape}, min: {original_with_contours.min()}, max: {original_with_contours.max()}")
|
|
423
|
+
|
|
424
|
+
# Use the original image with contours
|
|
425
|
+
result = original_with_contours
|
|
426
|
+
|
|
427
|
+
print(f"Result shape: {result.shape}, min: {result.min()}, max: {result.max()}")
|
|
428
|
+
|
|
429
|
+
# Convert back to PIL Image
|
|
430
|
+
return Image.fromarray(result.astype(np.uint8))
|
|
431
|
+
|
|
432
|
+
except Exception as e:
|
|
433
|
+
print(f"Error in _apply_threshold: {e}")
|
|
434
|
+
# Return original image if threshold fails
|
|
435
|
+
return pil_image
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def plot_mapping_tpl(self, ax):
|
|
439
|
+
"""Plots the mapping of the wafer with coordinate points."""
|
|
440
|
+
ax.set_xlabel('X (cm)', fontsize=20)
|
|
441
|
+
ax.set_ylabel('Y (cm)', fontsize=20)
|
|
442
|
+
|
|
443
|
+
if self.coordinates is not None:
|
|
444
|
+
# Get all coordinates
|
|
445
|
+
x_coords = self.coordinates['X']
|
|
446
|
+
y_coords = self.coordinates['Y']
|
|
447
|
+
defect_size = self.coordinates['defect_size']
|
|
448
|
+
|
|
449
|
+
# Determine color based on mode
|
|
450
|
+
if self.is_complus4t_mode:
|
|
451
|
+
# Mode COMPLUS4T: color based on slider threshold
|
|
452
|
+
threshold = 0.0 # Default threshold
|
|
453
|
+
result = self.button_frame.get_selected_image()
|
|
454
|
+
if result is not None:
|
|
455
|
+
threshold = result[0] # Slider value in nm
|
|
456
|
+
|
|
457
|
+
# Red if size >= threshold, blue otherwise
|
|
458
|
+
colors = ['blue' if size >= threshold else 'red' for size in defect_size]
|
|
459
|
+
else:
|
|
460
|
+
# Normal mode: color based on fixed threshold (10 nm)
|
|
461
|
+
colors = ['blue' if size > 1.0e+01 else 'red' for size in defect_size]
|
|
462
|
+
|
|
463
|
+
ax.scatter(x_coords, y_coords, color=colors, marker='o',
|
|
464
|
+
s=100, label='Positions')
|
|
465
|
+
|
|
466
|
+
# Calculate the maximum value for scaling using ALL coordinates
|
|
467
|
+
if len(self.coordinates) == 0:
|
|
468
|
+
# No defects found, use default radius
|
|
469
|
+
radius = 10
|
|
470
|
+
max_val = 10
|
|
471
|
+
else:
|
|
472
|
+
x_coords_all = self.coordinates['X']
|
|
473
|
+
y_coords_all = self.coordinates['Y']
|
|
474
|
+
max_val = max(abs(x_coords_all).max(), abs(y_coords_all).max())
|
|
475
|
+
|
|
476
|
+
# Check for NaN or Inf values
|
|
477
|
+
if pd.isna(max_val) or not np.isfinite(max_val):
|
|
478
|
+
radius = 10
|
|
479
|
+
max_val = 10
|
|
480
|
+
elif max_val <= 5:
|
|
481
|
+
radius = 5
|
|
482
|
+
elif max_val <= 7.5:
|
|
483
|
+
radius = 7.5
|
|
484
|
+
elif max_val <= 10:
|
|
485
|
+
radius = 10
|
|
486
|
+
elif max_val <= 15:
|
|
487
|
+
radius = 15
|
|
488
|
+
else:
|
|
489
|
+
radius = max_val # fallback for > 15
|
|
490
|
+
|
|
491
|
+
self.radius = radius
|
|
492
|
+
|
|
493
|
+
# Set limits based on the radius
|
|
494
|
+
ax.set_xlim(-radius - 1, radius + 1)
|
|
495
|
+
ax.set_ylim(-radius - 1, radius + 1)
|
|
496
|
+
|
|
497
|
+
circle = plt.Circle((0, 0), radius, color='black',
|
|
498
|
+
fill=False, linewidth=0.5)
|
|
499
|
+
ax.add_patch(circle)
|
|
500
|
+
ax.set_aspect('equal')
|
|
501
|
+
|
|
502
|
+
# Add grid
|
|
503
|
+
ax.grid(True, alpha=0.3, linestyle='-', linewidth=0.5)
|
|
504
|
+
ax.set_axisbelow(True)
|
|
505
|
+
else:
|
|
506
|
+
# No coordinates available
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
ax.figure.subplots_adjust(left=0.15, right=0.95, top=0.90, bottom=0.1)
|
|
511
|
+
self.canvas.draw()
|
|
512
|
+
|
|
513
|
+
def on_click(self, event):
|
|
514
|
+
"""
|
|
515
|
+
Handles mouse click events on the plot, identifying the closest point
|
|
516
|
+
and updating the plot with a red circle around the selected point.
|
|
517
|
+
|
|
518
|
+
:param event: The event generated by the mouse click.
|
|
519
|
+
"""
|
|
520
|
+
result = self.button_frame.get_selected_image()
|
|
521
|
+
if result is not None:
|
|
522
|
+
self.image_type, self_number_type = result
|
|
523
|
+
else:
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
if event.inaxes:
|
|
527
|
+
x_pos = event.xdata
|
|
528
|
+
y_pos = event.ydata
|
|
529
|
+
|
|
530
|
+
# Store the clicked position for filename generation (will be updated with closest point)
|
|
531
|
+
self.last_clicked_position = (x_pos, y_pos)
|
|
532
|
+
|
|
533
|
+
if self.coordinates is not None and not self.coordinates.empty:
|
|
534
|
+
distances = np.sqrt((self.coordinates['X'] - x_pos) ** 2 +
|
|
535
|
+
(self.coordinates['Y'] - y_pos) ** 2)
|
|
536
|
+
closest_idx = distances.idxmin()
|
|
537
|
+
closest_pt = self.coordinates.iloc[closest_idx]
|
|
538
|
+
|
|
539
|
+
# Update stored position with closest point coordinates
|
|
540
|
+
self.last_clicked_position = (closest_pt['X'], closest_pt['Y'])
|
|
541
|
+
|
|
542
|
+
# Replot with a red circle around the selected point
|
|
543
|
+
self.ax.clear() # Clear the existing plot
|
|
544
|
+
self.plot_mapping_tpl(self.ax)
|
|
545
|
+
self.ax.scatter([closest_pt['X']], [closest_pt['Y']],
|
|
546
|
+
color='green', marker='o', s=100,
|
|
547
|
+
label='Selected point')
|
|
548
|
+
coord_text = f"{closest_pt['X']:.1f} / {closest_pt['Y']:.1f}"
|
|
549
|
+
self.ax.text(-self.radius -0.5, self.radius-0.5, coord_text, fontsize=16, color='black')
|
|
550
|
+
self.canvas.draw()
|
|
551
|
+
|
|
552
|
+
# Update the image based on the selected point
|
|
553
|
+
if self.is_complus4t_mode:
|
|
554
|
+
# COMPLUS4T mode: use DEFECTID from KLARF file
|
|
555
|
+
defect_id = int(closest_pt['defect_id'])
|
|
556
|
+
# DEFECTID starts at 1, but Python indices start at 0
|
|
557
|
+
result = defect_id - 1
|
|
558
|
+
else:
|
|
559
|
+
# Normal mode: use DataFrame index (original behavior)
|
|
560
|
+
result = self.image_type + (closest_idx * self_number_type)
|
|
561
|
+
|
|
562
|
+
self.current_index = result
|
|
563
|
+
|
|
564
|
+
# Check if index is valid
|
|
565
|
+
if 0 <= self.current_index < len(self.image_list):
|
|
566
|
+
self.show_image()
|
|
567
|
+
|
|
568
|
+
def _load_tiff(self, tiff_path):
|
|
569
|
+
"""Load and prepare TIFF images for display.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
tiff_path: Path to the TIFF file to load
|
|
573
|
+
"""
|
|
574
|
+
try:
|
|
575
|
+
self.current_tiff_path = tiff_path # Store the current TIFF path
|
|
576
|
+
img = Image.open(tiff_path)
|
|
577
|
+
self.image_list = []
|
|
578
|
+
|
|
579
|
+
# Load all TIFF pages and resize them
|
|
580
|
+
while True:
|
|
581
|
+
resized_img = img.copy().resize((CANVAS_SIZE, CANVAS_SIZE),
|
|
582
|
+
Image.Resampling.LANCZOS)
|
|
583
|
+
self.image_list.append(resized_img)
|
|
584
|
+
try:
|
|
585
|
+
img.seek(img.tell() + 1) # Move to next page
|
|
586
|
+
except EOFError:
|
|
587
|
+
break # No more pages
|
|
588
|
+
|
|
589
|
+
self.current_index = 0
|
|
590
|
+
self.show_image() # Display first image
|
|
591
|
+
|
|
592
|
+
except Exception as e:
|
|
593
|
+
# Error loading TIFF file
|
|
594
|
+
pass
|
|
595
|
+
self._reset_display()
|
|
596
|
+
|
|
597
|
+
def show_overview(self):
|
|
598
|
+
"""Show overview of the wafer data with image thumbnails."""
|
|
599
|
+
print("[DEBUG] show_overview called")
|
|
600
|
+
|
|
601
|
+
# Check if coordinates and images are available
|
|
602
|
+
if self.coordinates is None or (hasattr(self.coordinates, 'empty') and self.coordinates.empty):
|
|
603
|
+
print("[DEBUG] No coordinates available")
|
|
604
|
+
msg = QMessageBox()
|
|
605
|
+
msg.setIcon(QMessageBox.Warning)
|
|
606
|
+
msg.setText("No coordinates available. Please open a TIFF file first.")
|
|
607
|
+
msg.setWindowTitle("Overview")
|
|
608
|
+
msg.setStyleSheet(MESSAGE_BOX_STYLE)
|
|
609
|
+
msg.exec_()
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
if not self.image_list or len(self.image_list) == 0:
|
|
613
|
+
print("[DEBUG] No images available")
|
|
614
|
+
msg = QMessageBox()
|
|
615
|
+
msg.setIcon(QMessageBox.Warning)
|
|
616
|
+
msg.setText("No images available. Please open a TIFF file first.")
|
|
617
|
+
msg.setWindowTitle("Overview")
|
|
618
|
+
msg.setStyleSheet(MESSAGE_BOX_STYLE)
|
|
619
|
+
msg.exec_()
|
|
620
|
+
return
|
|
621
|
+
|
|
622
|
+
print(f"[DEBUG] Coordinates: {len(self.coordinates)} entries")
|
|
623
|
+
print(f"[DEBUG] Images: {len(self.image_list)} images")
|
|
624
|
+
print(f"[DEBUG] COMPLUS4T mode: {self.is_complus4t_mode}")
|
|
625
|
+
|
|
626
|
+
# Get image type settings for normal mode
|
|
627
|
+
image_type = None
|
|
628
|
+
number_type = None
|
|
629
|
+
if not self.is_complus4t_mode and self.button_frame:
|
|
630
|
+
result = self.button_frame.get_selected_image()
|
|
631
|
+
if result is not None:
|
|
632
|
+
image_type, number_type = result
|
|
633
|
+
print(f"[DEBUG] Image type: {image_type}, Number type: {number_type}")
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
# Close previous overview window if it exists
|
|
637
|
+
if self.overview_window is not None:
|
|
638
|
+
self.overview_window.close()
|
|
639
|
+
self.overview_window = None
|
|
640
|
+
|
|
641
|
+
# Create and show overview window
|
|
642
|
+
print("[DEBUG] Creating OverviewWindow...")
|
|
643
|
+
self.overview_window = OverviewWindow(
|
|
644
|
+
coordinates=self.coordinates,
|
|
645
|
+
image_list=self.image_list,
|
|
646
|
+
tiff_path=self.current_tiff_path,
|
|
647
|
+
is_complus4t_mode=self.is_complus4t_mode,
|
|
648
|
+
image_type=image_type,
|
|
649
|
+
number_type=number_type,
|
|
650
|
+
button_frame=self.button_frame,
|
|
651
|
+
parent=None # Set parent to None to make it a separate window
|
|
652
|
+
)
|
|
653
|
+
print("[DEBUG] OverviewWindow created, showing...")
|
|
654
|
+
|
|
655
|
+
# Process events to ensure window is ready
|
|
656
|
+
from PyQt5.QtWidgets import QApplication
|
|
657
|
+
QApplication.processEvents()
|
|
658
|
+
|
|
659
|
+
# Show the window first
|
|
660
|
+
self.overview_window.show()
|
|
661
|
+
QApplication.processEvents()
|
|
662
|
+
|
|
663
|
+
# Then set to fullscreen
|
|
664
|
+
from PyQt5.QtCore import Qt
|
|
665
|
+
self.overview_window.setWindowState(Qt.WindowFullScreen)
|
|
666
|
+
QApplication.processEvents()
|
|
667
|
+
|
|
668
|
+
# Bring to front
|
|
669
|
+
self.overview_window.raise_()
|
|
670
|
+
self.overview_window.activateWindow()
|
|
671
|
+
QApplication.processEvents()
|
|
672
|
+
|
|
673
|
+
print(f"[DEBUG] OverviewWindow should be visible now. Is visible: {self.overview_window.isVisible()}, Is active: {self.overview_window.isActiveWindow()}")
|
|
674
|
+
except Exception as e:
|
|
675
|
+
print(f"[DEBUG] Error creating/showing OverviewWindow: {e}")
|
|
676
|
+
import traceback
|
|
677
|
+
traceback.print_exc()
|
|
678
|
+
msg = QMessageBox()
|
|
679
|
+
msg.setIcon(QMessageBox.Critical)
|
|
680
|
+
msg.setText(f"Error opening overview: {str(e)}")
|
|
681
|
+
msg.setWindowTitle("Error")
|
|
682
|
+
msg.setStyleSheet(MESSAGE_BOX_STYLE)
|
|
683
|
+
msg.exec_()
|
|
684
|
+
|
|
685
|
+
def get_current_image_path(self):
|
|
686
|
+
"""Get the path of the currently displayed image."""
|
|
687
|
+
if self.current_tiff_path and self.image_list:
|
|
688
|
+
return self.current_tiff_path
|
|
689
|
+
return None
|
|
690
|
+
|