File size: 13,782 Bytes
e916c68
 
 
bf87041
 
945b29b
bf87041
 
 
 
 
 
 
 
 
 
9dc758e
bf87041
 
 
 
67dea58
bf87041
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67dea58
bf87041
 
 
67dea58
bf87041
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67dea58
bf87041
 
 
67dea58
bf87041
 
67dea58
bf87041
 
67dea58
 
 
 
 
 
e916c68
bf87041
 
 
 
67dea58
bf87041
 
 
 
67dea58
bf87041
 
 
 
 
67dea58
 
bf87041
67dea58
 
bf87041
67dea58
 
bf87041
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e916c68
bf87041
e916c68
 
bf87041
 
 
 
 
 
 
 
 
 
 
 
 
 
57527ae
bf87041
 
57527ae
bf87041
 
 
9dc758e
bf87041
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57527ae
 
e916c68
57527ae
bf87041
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e916c68
bf87041
 
 
 
 
 
 
 
 
 
e916c68
bf87041
 
 
 
 
e916c68
bf87041
 
57527ae
bf87041
 
 
 
 
 
e916c68
bf87041
 
 
 
e916c68
bf87041
e916c68
9dc758e
bf87041
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
import gradio as gr
import cv2
import numpy as np
import pandas as pd
import os

def process_image(image, txt_file, crop_size, blur_kernel_size, clahe_clip_limit, clahe_tile_grid_size, adaptive_thresh_block_size, adaptive_thresh_c, close_kernel_size_morph, open_kernel_size_morph, min_contour_area, max_contour_area, min_circularity, max_circularity, selection_method, px_per_um, manual_offset_x, manual_offset_y):
    # Convert Gradio image (RGB) to grayscale if it\\'s not already
    if len(image.shape) == 3 and image.shape[2] == 3:
        image_gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
    else:
        image_gray = image # Assume it\\\'s already grayscale

    # Step 1: Crop to Center to Focus on Small Cell
    height, width = image_gray.shape
    center_x, center_y = width // 2, height // 2
    
    y_start = max(0, center_y - crop_size // 2)
    x_start = max(0, center_x - crop_size // 2)
    y_end = min(height, center_y + crop_size // 2)
    x_end = min(width, center_x + crop_size // 2)
    
    img_crop = image_gray[y_start:y_end, x_start:x_end]

    # Step 2: Enhance Contrast (CLAHE)
    clahe = cv2.createCLAHE(clipLimit=clahe_clip_limit, tileGridSize=(clahe_tile_grid_size, clahe_tile_grid_size))
    enhanced_crop = clahe.apply(img_crop)

    # Step 3: Apply Gaussian blur to smooth and highlight contrasts
    # This blurred_crop is used for adaptive thresholding in Colab
    blurred_crop = cv2.GaussianBlur(enhanced_crop, (blur_kernel_size, blur_kernel_size), 0)

    # Step 4: Adaptive Thresholding
    thresh = cv2.adaptiveThreshold(blurred_crop, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                   cv2.THRESH_BINARY_INV, adaptive_thresh_block_size, adaptive_thresh_c)

    # Step 5: Morphological Operations (after adaptive thresholding)
    close_kernel = np.ones((close_kernel_size_morph, close_kernel_size_morph), np.uint8)
    thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_kernel)
    open_kernel = np.ones((open_kernel_size_morph, open_kernel_size_morph), np.uint8)
    thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, open_kernel)

    # Step 6: Connected Component Analysis (Crucial for isolating the main cell)
    num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(thresh, 8, cv2.CV_32S)
    best_component_mask = np.zeros_like(thresh, dtype=np.uint8)
    
    # Center of the cropped image for distance calculation, defined here for scope
    img_crop_center_x, img_crop_center_y = img_crop.shape[1] // 2, img_crop.shape[0] // 2

    if num_labels > 1: # Exclude background
        max_area = 0
        best_label = -1
        
        for i in range(1, num_labels): # Iterate through components, skipping background (label 0)
            area = stats[i, cv2.CC_STAT_AREA]
            # Filter by area (adjust min/max as needed for cell size)
            if 200 < area < 200000: # These values are from Colab, can be exposed as Gradio sliders if needed
                # Calculate distance from centroid to image center
                centroid_x, centroid_y = centroids[i]
                distance = np.sqrt((centroid_x - img_crop_center_x)**2 + (centroid_y - img_crop_center_y)**2)
                
                # Prioritize components closer to the center and with reasonable area
                if area > max_area and distance < crop_size / 4: # Consider components within central quarter
                    max_area = area
                    best_label = i

        if best_label != -1:
            best_component_mask[labels == best_label] = 255
            thresh = best_component_mask # Use the best component as the final thresholded image
        else:
            thresh = np.zeros_like(thresh) # No suitable component found
    else:
        thresh = np.zeros_like(thresh) # No components found (or only background)

    # Step 7: Find Contours on the (now clean) thresholded image
    contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    filtered_contours = []
    if contours:
        for i, cnt in enumerate(contours):
            area = cv2.contourArea(cnt)
            perimeter = cv2.arcLength(cnt, True)
            circularity = 0
            if perimeter > 0:
                circularity = 4 * np.pi * (area / (perimeter * perimeter))
            
            # Apply contour filtering parameters from Colab
            if min_contour_area < area < max_contour_area:
                if min_circularity < circularity < max_circularity:
                    filtered_contours.append(cnt)

    # Step 8: Select Best Contour
    best_contour = None
    if filtered_contours:
        if selection_method == "Largest Area":
            best_contour = max(filtered_contours, key=cv2.contourArea)
        elif selection_method == "Most Circular":
            # Calculate circularity for each contour and find the one closest to 1
            best_contour = min(filtered_contours, key=lambda cnt: abs(1 - (4 * np.pi * cv2.contourArea(cnt) / (cv2.arcLength(cnt, True)**2))))
        elif selection_method == "Closest to Center":
            # img_crop_center_x, img_crop_center_y are already defined above
            def dist_to_center(cnt):
                M = cv2.moments(cnt)
                if M["m00"] == 0: return float("inf")
                cx = int(M["m10"] / M["m00"])
                cy = int(M["m01"] / M["m00"])
                return np.sqrt((cx - img_crop_center_x)**2 + (cy - img_crop_center_y)**2)
            best_contour = min(filtered_contours, key=dist_to_center)

    # Step 9: Final Visualization
    # Create a BGR image from the original (full size) for drawing
    final_image_display = cv2.cvtColor(image_gray, cv2.COLOR_GRAY2BGR)
    
    # Adjust best_contour to full image coordinates for drawing
    best_contour_full_coords = None
    ellipse_full_coords = None
    if best_contour is not None:
        # Shift contour points from cropped coordinates back to full image coordinates
        best_contour_full_coords = best_contour + np.array([x_start, y_start])
        cv2.drawContours(final_image_display, [best_contour_full_coords], -1, (0, 0, 255), 2) # Draw in red
        
        if len(best_contour) >= 5: # Need at least 5 points to fit an ellipse
            ellipse = cv2.fitEllipse(best_contour)
            (ell_center_x, ell_center_y), (major, minor), angle = ellipse
            # Shift ellipse center from cropped coordinates back to full image coordinates
            ellipse_full_coords = ((ell_center_x + x_start, ell_center_y + y_start), (major, minor), angle)
            cv2.ellipse(final_image_display, ellipse_full_coords, (0, 255, 0), 2) # Draw in green

    # Process TXT file if provided
    modified_txt_output = None
    if txt_file is not None:
        df = pd.read_csv(txt_file.name, sep="\\s+", header=None)
        if df.shape[1] < 4:
            raise ValueError("TXT must have at least 4 columns: x_um, y_um, z_um/wavelength, count")
        df.columns = ["x_um", "y_um", "z_um", "count"]
        
        # Adjust y_um for large offset (normalize around 0 to ignore absolute stage position)
        df["y_um"] = df["y_um"] - df["y_um"].mean()

        # Calculate pixel coordinates for data points
        # Use ellipse center for offset if available, otherwise image center
        if ellipse_full_coords is not None:
            ellipse_center_x_full = ellipse_full_coords[0][0]
            ellipse_center_y_full = ellipse_full_coords[0][1]
        else:
            ellipse_center_x_full = width // 2
            ellipse_center_y_full = height // 2

        x_um_mean = df["x_um"].mean() + manual_offset_x
        y_um_mean = df["y_um"].mean() + manual_offset_y
        
        df["x_px"] = (df["x_um"] - x_um_mean) * px_per_um + ellipse_center_x_full
        df["y_px"] = (df["y_um"] - y_um_mean) * px_per_um + ellipse_center_y_full
        
        df["inside"] = df.apply(lambda row: cv2.pointPolygonTest(best_contour_full_coords, (row["x_px"], row["y_px"]), False) >= 0 if best_contour_full_coords is not None else False, axis=1)
        # Set \'count\' to 0 for points outside the contour
        df.loc[df["inside"] == False, "count"] = 0

        for index, row in df.iterrows():
            # Draw points on the full image
            color = (255, 0, 0) if row["inside"] else (0, 165, 255) # Blue for inside, Orange for outside
            cv2.circle(final_image_display, (int(row["x_px"]), int(row["y_px"])), 3, color, -1)

        # Save modified TXT
        modified_txt_path = "modified_streamline.txt"
        df_output = df[["x_um", "y_um", "z_um", "count"]].copy() # Ensure \'count\' column is the modified one
        df_output.to_csv(modified_txt_path, sep="\t", index=False, header=False, float_format="%.6f")
        modified_txt_output = modified_txt_path

    return final_image_display, modified_txt_output

with gr.Blocks() as demo:
    gr.Markdown("## Cell Image Visualization Tool - Step by Step")
    gr.Markdown("Process your cell images step-by-step to detect boundaries and visualize data points.")
    
    with gr.Row():
        with gr.Column(scale=1):
            gr.Markdown("### File Upload")
            image_input = gr.Image(type="numpy", label="Cell Image (BMP)")
            txt_input = gr.File(label="Data File (TXT)")

            gr.Markdown("### Step 1: Preprocess Image")
            with gr.Accordion("Adjust Preprocessing Parameters", open=True):
                crop_size = gr.Slider(100, 2000, value=1000, step=100, label="Crop Size (pixels)", info="Size of the square crop around the image center.")
                blur_kernel_size = gr.Slider(3, 15, step=2, value=7, label="Gaussian Blur Kernel Size (odd)", info="Size of the kernel for Gaussian blur. Must be odd.")
                clahe_clip_limit = gr.Slider(1, 100, value=50, step=1, label="CLAHE Clip Limit", info="Threshold for contrast limiting.")
                clahe_tile_grid_size = gr.Slider(2, 16, step=2, value=4, label="CLAHE Tile Grid Size", info="Size of grid for histogram equalization. e.g., 4 means 4x4 grid.")

            gr.Markdown("### Step 2: Adaptive Thresholding")
            with gr.Accordion("Adjust Thresholding Parameters", open=True):
                adaptive_thresh_block_size = gr.Slider(3, 101, step=2, value=41, label="Adaptive Threshold Block Size (odd)", info="Size of a pixel neighborhood that is used to calculate a threshold value. Must be odd.")
                adaptive_thresh_c = gr.Slider(1, 50, value=10, step=1, label="Adaptive Threshold C Value", info="Constant subtracted from the mean or weighted mean.")

            gr.Markdown("### Step 3: Morphological Operations")
            with gr.Accordion("Adjust Morphological Kernels", open=True):
                close_kernel_size_morph = gr.Slider(3, 15, step=2, value=9, label="Closing Kernel Size (odd)", info="Kernel size for morphological closing. Helps fill small holes. Must be odd.")
                open_kernel_size_morph = gr.Slider(3, 15, step=2, value=5, label="Opening Kernel Size (odd)", info="Kernel size for morphological opening. Helps remove small objects. Must be odd.")
            
            gr.Markdown("### Step 4: Find and Filter Contours")
            with gr.Accordion("Adjust Contour Filtering", open=True):
                min_contour_area = gr.Slider(0, 1000, value=100, step=10, label="Min Contour Area (pixels)", info="Minimum area for a contour to be considered.")
                max_contour_area = gr.Slider(1000, 1000000, value=500000, step=1000, label="Max Contour Area (pixels)", info="Maximum area for a contour to be considered.")
                min_circularity = gr.Slider(0.001, 1.0, value=0.010, step=0.001, label="Min Circularity", info="Minimum circularity (4π * Area / Perimeter^2) for a contour. 1.0 is a perfect circle.")
                max_circularity = gr.Slider(1.0, 2.0, value=1.2, step=0.01, label="Max Circularity", info="Maximum circularity for a contour.")

            gr.Markdown("### Step 5: Select Best Contour")
            with gr.Accordion("Choose Selection Method", open=True):
                selection_method = gr.Radio(["Largest Area", "Most Circular", "Closest to Center"], label="Selection Method", value="Closest to Center", info="Method to select the single best contour if multiple are found.")
            
            gr.Markdown("### Step 6: Final Visualization & Data Processing")
            with gr.Accordion("Adjust Visualization and Data Parameters", open=True):
                px_per_um = gr.Slider(0.1, 50, value=21.3, step=0.1, label="Pixels per Micrometer (px/µm)", info="Scale factor to convert micrometers to pixels.")
                manual_offset_x = gr.Slider(-100, 100, value=0, step=1, label="Manual Offset X (µm)", info="Manual adjustment for X-coordinate offset in micrometers.")
                manual_offset_y = gr.Slider(-100, 100, value=0, step=1, label="Manual Offset Y (µm)", info="Manual adjustment for Y-coordinate offset in micrometers.")
            
            run_button = gr.Button("Run Cell Vision Analysis")

        with gr.Column(scale=1):
            gr.Markdown("### Results")
            output_image = gr.Image(label="Processed Image with Detections")
            output_txt = gr.File(label="Download Modified TXT File")

    run_button.click(
        fn=process_image,
        inputs=[
            image_input, txt_input, crop_size, blur_kernel_size, clahe_clip_limit, clahe_tile_grid_size, 
            adaptive_thresh_block_size, adaptive_thresh_c, close_kernel_size_morph, open_kernel_size_morph, 
            min_contour_area, max_contour_area, min_circularity, max_circularity, selection_method, 
            px_per_um, manual_offset_x, manual_offset_y
        ],
        outputs=[output_image, output_txt]
    )

demo.launch()