fspi-analysis 0.0.1__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.
- fspi_analysis/__init__.py +10 -0
- fspi_analysis/_tests/__init__.py +0 -0
- fspi_analysis/fspi_gui.py +465 -0
- fspi_analysis/napari.yaml +12 -0
- fspi_analysis-0.0.1.dist-info/METADATA +781 -0
- fspi_analysis-0.0.1.dist-info/RECORD +10 -0
- fspi_analysis-0.0.1.dist-info/WHEEL +5 -0
- fspi_analysis-0.0.1.dist-info/entry_points.txt +2 -0
- fspi_analysis-0.0.1.dist-info/licenses/LICENSE +675 -0
- fspi_analysis-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
2
|
+
|
|
3
|
+
# src/fspi_analysis/__init__.py
|
|
4
|
+
|
|
5
|
+
# ---- Start of new debug block ---- Used this to debug the import issue
|
|
6
|
+
# import sys
|
|
7
|
+
# print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
|
|
8
|
+
# print("DEBUG: TOP LEVEL OF src/fspi_analysis/__init__.py IS BEING EXECUTED NOW!")
|
|
9
|
+
# print(f"DEBUG: Current time: {__import__('datetime').datetime.now()}")
|
|
10
|
+
# sys.stdout.flush() # Force output to terminal
|
|
File without changes
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import napari
|
|
2
|
+
from magicgui import magicgui, widgets
|
|
3
|
+
from skimage import data, io, filters, color, util
|
|
4
|
+
from magicgui.widgets import PushButton, Container, FloatSlider, IntSlider, Label, FloatSpinBox
|
|
5
|
+
import pathlib
|
|
6
|
+
import traceback
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# --- Global variables specific to the widget's state (if any) ---last_loaded_directory = None
|
|
14
|
+
last_loaded_directory = None
|
|
15
|
+
last_loaded_stem = None
|
|
16
|
+
status_label = widgets.Label(value="Welcome! Load an image to start.")
|
|
17
|
+
path_min_y = None
|
|
18
|
+
path_max_y = None
|
|
19
|
+
path_y_difference = None
|
|
20
|
+
confirmed_y_depth = None # This will store the user-confirmed Y value
|
|
21
|
+
depth_line_layer_name = 'depth_selection_line'
|
|
22
|
+
depth_slider = IntSlider(value=100, min=0, max=200, label="Depth (Y):", visible=False)
|
|
23
|
+
confirm_depth_button = PushButton(text="Confirm Depth Y", visible=False)
|
|
24
|
+
determine_depth_button = PushButton(text="Determine Custom Depth")
|
|
25
|
+
threshold_value_widget = FloatSlider(value=0.5, min=0.0, max=1.0, step=0.005, label="Threshold:")
|
|
26
|
+
real_core_size_widget = FloatSpinBox(value=6, min=0.001, step=0.1, label="Real Core Size (cm):")
|
|
27
|
+
core_width_px_widget = FloatSpinBox(value=671, min=1.0, step=1, label="Core Width (px): ")
|
|
28
|
+
viewer = napari.current_viewer()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --- align_image_to_path function (stores path Y stats) ---
|
|
33
|
+
def align_image_to_path(image_layer, shapes_layer, viewer):
|
|
34
|
+
global status_label, path_min_y, path_max_y, path_y_difference
|
|
35
|
+
if not image_layer: status_label.value = "ERROR: Image layer not found for alignment."; return False
|
|
36
|
+
if not shapes_layer: status_label.value = "ERROR: Shapes layer not found for alignment."; return False
|
|
37
|
+
try:
|
|
38
|
+
original_image = image_layer.data; H, W = original_image.shape[:2]
|
|
39
|
+
if not shapes_layer.data: status_label.value = "No alignment path drawn."; return False
|
|
40
|
+
last_path_data_for_stats = None; last_path_data_for_alignment = None
|
|
41
|
+
for i in range(len(shapes_layer.data) - 1, -1, -1):
|
|
42
|
+
current_shape_type_list_or_str = shapes_layer.shape_type; actual_shape_type = ''
|
|
43
|
+
if isinstance(current_shape_type_list_or_str, list):
|
|
44
|
+
if i < len(current_shape_type_list_or_str): actual_shape_type = current_shape_type_list_or_str[i]
|
|
45
|
+
else: continue
|
|
46
|
+
else: actual_shape_type = current_shape_type_list_or_str
|
|
47
|
+
if actual_shape_type == 'path':
|
|
48
|
+
if i < len(shapes_layer.data):
|
|
49
|
+
last_path_data_for_alignment = shapes_layer.data[i]
|
|
50
|
+
last_path_data_for_stats = shapes_layer.data[i]
|
|
51
|
+
break
|
|
52
|
+
if last_path_data_for_alignment is None: status_label.value = "No 'path' shape found for alignment."; return False
|
|
53
|
+
if last_path_data_for_alignment.shape[1] != 2 or last_path_data_for_alignment.shape[0] < 1: status_label.value = "Path data is invalid."; return False
|
|
54
|
+
if last_path_data_for_stats is not None and last_path_data_for_stats.shape[0] > 0:
|
|
55
|
+
path_y_coords = last_path_data_for_stats[:, 0]
|
|
56
|
+
path_min_y = float(np.min(path_y_coords)); path_max_y = float(np.max(path_y_coords))
|
|
57
|
+
path_y_difference = path_max_y - path_min_y
|
|
58
|
+
else: path_min_y, path_max_y, path_y_difference = None, None, None
|
|
59
|
+
polyline_coords = last_path_data_for_alignment; sorted_indices = np.argsort(polyline_coords[:, 1])
|
|
60
|
+
px_sorted, py_sorted = polyline_coords[sorted_indices, 1], polyline_coords[sorted_indices, 0]
|
|
61
|
+
unique_px, unique_indices = np.unique(px_sorted, return_index=True); unique_py = py_sorted[unique_indices]
|
|
62
|
+
if unique_px.size == 0 : status_label.value = "No valid points for interpolation."; return False
|
|
63
|
+
if unique_px.size < 2:
|
|
64
|
+
if unique_px.size == 1: y_val_for_all_cols = np.mean(py_sorted); interpolated_y_line = np.full(W, y_val_for_all_cols)
|
|
65
|
+
else: status_label.value = "Not enough unique x-points for alignment path."; return False
|
|
66
|
+
else: interpolated_y_line = np.interp(np.arange(W), unique_px, unique_py)
|
|
67
|
+
if original_image.ndim == 3 and original_image.shape[-1] > 1: aligned_image = np.zeros_like(original_image)
|
|
68
|
+
else: aligned_image = np.zeros_like(original_image)
|
|
69
|
+
for x_col in range(W):
|
|
70
|
+
y_line_at_x = int(round(interpolated_y_line[x_col])); y_line_at_x_clipped = np.clip(y_line_at_x, 0, H - 1)
|
|
71
|
+
pixels_to_crop_from_top = y_line_at_x_clipped; num_pixels_to_keep = H - pixels_to_crop_from_top
|
|
72
|
+
if num_pixels_to_keep > 0:
|
|
73
|
+
source_start_y, source_end_y = pixels_to_crop_from_top, H
|
|
74
|
+
dest_start_y, dest_end_y = 0, num_pixels_to_keep
|
|
75
|
+
if original_image.ndim == 2: aligned_image[dest_start_y:dest_end_y, x_col] = original_image[source_start_y:source_end_y, x_col]
|
|
76
|
+
elif original_image.ndim == 3: aligned_image[dest_start_y:dest_end_y, x_col, :] = original_image[source_start_y:source_end_y, x_col, :]
|
|
77
|
+
try:
|
|
78
|
+
aligned_layer = viewer.layers['aligned_image']; aligned_layer.data = aligned_image
|
|
79
|
+
aligned_layer.contrast_limits = image_layer.contrast_limits; aligned_layer.colormap = image_layer.colormap
|
|
80
|
+
aligned_layer.blending = image_layer.blending; aligned_layer.visible = True
|
|
81
|
+
except KeyError:
|
|
82
|
+
viewer.add_image(aligned_image, name='aligned_image', colormap=image_layer.colormap.name,
|
|
83
|
+
contrast_limits=image_layer.contrast_limits, blending=image_layer.blending)
|
|
84
|
+
num_points_in_path = 0
|
|
85
|
+
if last_path_data_for_stats is not None : num_points_in_path = last_path_data_for_stats.shape[0]
|
|
86
|
+
message = f"Image aligned. Path Y range: [{path_min_y:.1f}-{path_max_y:.1f}]." if path_min_y is not None else "Image aligned."
|
|
87
|
+
status_label.value = message
|
|
88
|
+
return True
|
|
89
|
+
except Exception as e: status_label.value = f"ERROR during alignment: {e}"; traceback.print_exc(); return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def clear_alignment_paths():
|
|
93
|
+
global viewer, status_label
|
|
94
|
+
try:
|
|
95
|
+
shapes_layer = viewer.layers['alignment_path']; shapes_layer.data = []
|
|
96
|
+
status_label.value = "Alignment paths cleared."
|
|
97
|
+
except (NameError, KeyError): status_label.value = "Could not find alignment_path layer to clear."
|
|
98
|
+
except Exception as e: status_label.value = f"Error clearing paths: {e}"
|
|
99
|
+
|
|
100
|
+
@magicgui(call_button="Load Image File", image_path={"label": "Choose Image:", "mode": "r"})
|
|
101
|
+
def load_new_image(image_path: pathlib.Path):
|
|
102
|
+
global viewer, last_loaded_directory, last_loaded_stem, status_label
|
|
103
|
+
reset_viewer_state(called_internally=True)
|
|
104
|
+
if not image_path or not image_path.is_file(): status_label.value = "No valid file selected."; return
|
|
105
|
+
status_label.value = f"Loading: {image_path.name}..."
|
|
106
|
+
try:
|
|
107
|
+
loaded_image_data = io.imread(image_path); original_layer_name = 'original_image'
|
|
108
|
+
try:
|
|
109
|
+
original_layer = viewer.layers[original_layer_name]; original_layer.data = loaded_image_data
|
|
110
|
+
original_layer.reset_contrast_limits()
|
|
111
|
+
except KeyError:
|
|
112
|
+
original_layer = viewer.add_image(loaded_image_data, name=original_layer_name)
|
|
113
|
+
original_layer.reset_contrast_limits()
|
|
114
|
+
last_loaded_directory = image_path.parent; last_loaded_stem = image_path.stem
|
|
115
|
+
shapes_layer_name = 'alignment_path'
|
|
116
|
+
try:
|
|
117
|
+
shapes_layer_index = viewer.layers.index(shapes_layer_name); target_index = len(viewer.layers) - 1
|
|
118
|
+
if shapes_layer_index != target_index: viewer.layers.move(shapes_layer_index, target_index)
|
|
119
|
+
except (ValueError, KeyError): pass
|
|
120
|
+
status_label.value = f"Loaded: {image_path.name}. Viewer reset."
|
|
121
|
+
except Exception as e: status_label.value = f"Error loading {image_path.name}: {e}"; traceback.print_exc()
|
|
122
|
+
|
|
123
|
+
def _get_thresholdable_gray_image(source_image_layer):
|
|
124
|
+
if source_image_layer is None: return None
|
|
125
|
+
image_data = source_image_layer.data; gray_image = None
|
|
126
|
+
try:
|
|
127
|
+
if image_data.ndim == 3 and image_data.shape[-1] in [3, 4]:
|
|
128
|
+
img_float = util.img_as_float(image_data[...,:3]); gray_image = color.rgb2gray(img_float)
|
|
129
|
+
elif image_data.ndim == 2:
|
|
130
|
+
if np.issubdtype(image_data.dtype, np.integer): gray_image = util.img_as_float(image_data)
|
|
131
|
+
elif image_data.min() >= 0 and image_data.max() <= 1: gray_image = image_data
|
|
132
|
+
else:
|
|
133
|
+
min_val, max_val = np.min(image_data), np.max(image_data)
|
|
134
|
+
if max_val > min_val: gray_image = (image_data - min_val) / (max_val - min_val)
|
|
135
|
+
else: gray_image = np.zeros_like(image_data, dtype=float)
|
|
136
|
+
else: return None
|
|
137
|
+
return gray_image
|
|
138
|
+
except Exception as e: print(f"ERROR in _get_thresholdable_gray_image: {e}"); traceback.print_exc(); return None
|
|
139
|
+
|
|
140
|
+
def calculate_auto_threshold():
|
|
141
|
+
global viewer, threshold_value_widget, status_label
|
|
142
|
+
status_label.value = "Calculating auto threshold..."
|
|
143
|
+
try:
|
|
144
|
+
aligned_layer = viewer.layers['aligned_image']; gray_image = _get_thresholdable_gray_image(aligned_layer)
|
|
145
|
+
if gray_image is not None:
|
|
146
|
+
auto_thresh_value = filters.threshold_otsu(gray_image); threshold_value_widget.value = auto_thresh_value
|
|
147
|
+
_update_threshold_preview()
|
|
148
|
+
try:
|
|
149
|
+
if 'original_image' in viewer.layers: viewer.layers['original_image'].visible = False
|
|
150
|
+
if 'aligned_image' in viewer.layers: viewer.layers['aligned_image'].visible = False
|
|
151
|
+
if 'thresholded_mask' in viewer.layers: viewer.layers['thresholded_mask'].visible = True
|
|
152
|
+
except KeyError: pass
|
|
153
|
+
status_label.value = f"Auto threshold: {auto_thresh_value:.4f}. Layers hidden."
|
|
154
|
+
else: status_label.value = "Could not prepare image for auto threshold."
|
|
155
|
+
except KeyError: status_label.value = "ERROR: 'aligned_image' layer not found. Align first."
|
|
156
|
+
except Exception as e: status_label.value = f"Error in auto threshold: {e}"; traceback.print_exc()
|
|
157
|
+
|
|
158
|
+
def _update_threshold_preview():
|
|
159
|
+
global viewer, threshold_value_widget, status_label; mask_layer_name = 'thresholded_mask'
|
|
160
|
+
try:
|
|
161
|
+
if 'aligned_image' not in viewer.layers: return
|
|
162
|
+
aligned_layer = viewer.layers['aligned_image']; gray_image = _get_thresholdable_gray_image(aligned_layer)
|
|
163
|
+
if gray_image is not None:
|
|
164
|
+
threshold = threshold_value_widget.value; binary_mask = gray_image > threshold
|
|
165
|
+
mask_int = binary_mask.astype(np.uint8)
|
|
166
|
+
try:
|
|
167
|
+
mask_layer = viewer.layers[mask_layer_name]; mask_layer.data = mask_int
|
|
168
|
+
except KeyError:
|
|
169
|
+
viewer.add_labels(mask_int, name=mask_layer_name, visible=True)
|
|
170
|
+
if mask_layer_name in viewer.layers:
|
|
171
|
+
viewer.layers[mask_layer_name].color_mode = 'direct'
|
|
172
|
+
viewer.layers[mask_layer_name].color = {0: 'transparent', 1: 'yellow'}
|
|
173
|
+
viewer.layers[mask_layer_name].opacity = 0.6
|
|
174
|
+
viewer.layers[mask_layer_name].visible = True
|
|
175
|
+
except Exception as e: status_label.value = f"Live preview error: {e}"; traceback.print_exc()
|
|
176
|
+
|
|
177
|
+
def apply_manual_threshold():
|
|
178
|
+
global viewer, threshold_value_widget, status_label
|
|
179
|
+
_update_threshold_preview()
|
|
180
|
+
if 'thresholded_mask' in viewer.layers:
|
|
181
|
+
status_label.value = f"Threshold {threshold_value_widget.value:.4f} applied. Mask updated."
|
|
182
|
+
elif 'aligned_image' not in viewer.layers:
|
|
183
|
+
status_label.value = "Cannot apply: 'aligned_image' layer not found."
|
|
184
|
+
else:
|
|
185
|
+
status_label.value = "Mask layer could not be created/updated."
|
|
186
|
+
|
|
187
|
+
def process_and_save():
|
|
188
|
+
global viewer, last_loaded_directory, last_loaded_stem, status_label
|
|
189
|
+
global path_min_y, path_max_y, path_y_difference, confirmed_y_depth
|
|
190
|
+
status_label.value = "Processing and saving results..."
|
|
191
|
+
|
|
192
|
+
if last_loaded_directory is None or last_loaded_stem is None:
|
|
193
|
+
status_label.value = "ERROR: No custom image loaded. Load image first."; return
|
|
194
|
+
if 'thresholded_mask' not in viewer.layers:
|
|
195
|
+
status_label.value = "ERROR: 'thresholded_mask' layer not found. Apply threshold first."; return
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
mask_layer = viewer.layers['thresholded_mask']
|
|
199
|
+
mask_data_full = mask_layer.data # This is the full mask
|
|
200
|
+
H_full, W = mask_data_full.shape
|
|
201
|
+
if W == 0: status_label.value = "ERROR: Mask width is zero."; return
|
|
202
|
+
except KeyError: status_label.value = "ERROR: Could not access 'thresholded_mask' data."; return
|
|
203
|
+
except Exception as e: status_label.value = f"ERROR accessing mask data: {e}"; traceback.print_exc(); return
|
|
204
|
+
|
|
205
|
+
# --- Save Mask Image (uses full mask_data_full) ---
|
|
206
|
+
try:
|
|
207
|
+
mask_filename = f"{last_loaded_stem}_Mask.png"
|
|
208
|
+
output_mask_path = last_loaded_directory / mask_filename
|
|
209
|
+
mask_to_save = (mask_data_full * 255).astype(np.uint8)
|
|
210
|
+
io.imsave(output_mask_path, mask_to_save, check_contrast=False)
|
|
211
|
+
print(f"Mask image saved to: {output_mask_path}")
|
|
212
|
+
except Exception as e: status_label.value = f"ERROR saving mask image: {e}"; traceback.print_exc(); return
|
|
213
|
+
|
|
214
|
+
# --- Calculations (Row sums on full mask, vertical distances on constrained mask) ---
|
|
215
|
+
try:
|
|
216
|
+
profile_filename = f"{last_loaded_stem}_Profile.csv"
|
|
217
|
+
output_profile_path = last_loaded_directory / profile_filename
|
|
218
|
+
|
|
219
|
+
# Row sums and relative count from the full mask
|
|
220
|
+
row_sums_full = np.sum(mask_data_full, axis=1)
|
|
221
|
+
depths_full = np.arange(H_full)
|
|
222
|
+
total_sum_of_mask_pixels_full = np.sum(mask_data_full)
|
|
223
|
+
if total_sum_of_mask_pixels_full > 0:
|
|
224
|
+
relative_counts_full = (row_sums_full / total_sum_of_mask_pixels_full) * 100
|
|
225
|
+
else:
|
|
226
|
+
relative_counts_full = np.zeros_like(row_sums_full, dtype=float)
|
|
227
|
+
|
|
228
|
+
# --- Vertical Distance Calculation ---
|
|
229
|
+
# Determine the mask to process for vertical distances based on confirmed_y_depth
|
|
230
|
+
mask_for_vertical_distances = mask_data_full # Default to full mask
|
|
231
|
+
effective_H_for_vertical_distances = H_full
|
|
232
|
+
|
|
233
|
+
if confirmed_y_depth is not None:
|
|
234
|
+
# Use rows 0 up to AND INCLUDING confirmed_y_depth
|
|
235
|
+
# Ensure confirmed_y_depth is within bounds of the mask
|
|
236
|
+
clamped_depth = min(int(confirmed_y_depth), H_full - 1)
|
|
237
|
+
roi_depth_info_str = f"Up to Y={clamped_depth} (inclusive)" ## For adding into summary text file later
|
|
238
|
+
if clamped_depth < 0 : # e.g. if confirmed_y_depth was 0, then +1 is 1, slice is :1 (row 0)
|
|
239
|
+
effective_H_for_vertical_distances = 0
|
|
240
|
+
else:
|
|
241
|
+
effective_H_for_vertical_distances = clamped_depth + 1
|
|
242
|
+
|
|
243
|
+
mask_for_vertical_distances = mask_data_full[:effective_H_for_vertical_distances, :]
|
|
244
|
+
print(f"Calculating vertical distances within Y range: 0-{clamped_depth} (inclusive)")
|
|
245
|
+
else:
|
|
246
|
+
print("No custom depth set, calculating vertical distances for full height.")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
vertical_distances_roi = []
|
|
250
|
+
if effective_H_for_vertical_distances > 0 and mask_for_vertical_distances.shape[0] > 0 : # Check if there are rows
|
|
251
|
+
for x_col in range(W):
|
|
252
|
+
column_data = mask_for_vertical_distances[:, x_col]
|
|
253
|
+
on_pixels_indices = np.where(column_data == 1)[0]
|
|
254
|
+
if on_pixels_indices.size > 0: # If at least one 'on' pixel
|
|
255
|
+
topmost_y = np.min(on_pixels_indices)
|
|
256
|
+
bottommost_y = np.max(on_pixels_indices)
|
|
257
|
+
distance = bottommost_y - topmost_y + 1 # Includes both ends
|
|
258
|
+
vertical_distances_roi.append(distance)
|
|
259
|
+
|
|
260
|
+
mean_vertical_distance_roi = np.nan
|
|
261
|
+
median_vertical_distance_roi = np.nan
|
|
262
|
+
if vertical_distances_roi:
|
|
263
|
+
mean_vertical_distance_roi = np.mean(vertical_distances_roi)
|
|
264
|
+
median_vertical_distance_roi = np.median(vertical_distances_roi)
|
|
265
|
+
max_vertical_distance_roi = np.max(vertical_distances_roi)
|
|
266
|
+
print(f"Mean Vertical Distance (ROI): {mean_vertical_distance_roi:.2f}")
|
|
267
|
+
print(f"Median Vertical Distance (ROI): {median_vertical_distance_roi:.2f}")
|
|
268
|
+
else:
|
|
269
|
+
print("No vertical distances calculated in ROI (e.g., empty mask region).")
|
|
270
|
+
# ---------------------------------------
|
|
271
|
+
|
|
272
|
+
#real_core_size_cm = 0.0
|
|
273
|
+
core_width_in_px = 0.0
|
|
274
|
+
pixel_size_cm_per_px = 0.0
|
|
275
|
+
if real_core_size_widget is not None and core_width_px_widget is not None:
|
|
276
|
+
real_core_size_cm = real_core_size_widget.value
|
|
277
|
+
core_width_in_px = core_width_px_widget.value
|
|
278
|
+
pixel_size_micron_per_px = (real_core_size_cm*1000) / core_width_in_px
|
|
279
|
+
mean_vertical_distance_scaled = (mean_vertical_distance_roi*pixel_size_micron_per_px)
|
|
280
|
+
median_vertical_distance_scaled = (median_vertical_distance_roi*pixel_size_micron_per_px)
|
|
281
|
+
max_vertical_distance_roi_scaled = (max_vertical_distance_roi*pixel_size_micron_per_px)
|
|
282
|
+
alignment_path_scaled = (path_y_difference * pixel_size_micron_per_px)
|
|
283
|
+
|
|
284
|
+
# Create pandas DataFrame
|
|
285
|
+
# The per-row data should match the full height of the mask
|
|
286
|
+
df_data = {
|
|
287
|
+
'Row': depths_full,
|
|
288
|
+
'Row_Sums': row_sums_full,
|
|
289
|
+
'Row_Sum_Relative_Count': relative_counts_full,
|
|
290
|
+
'Surface_Path_Difference_Microns': alignment_path_scaled if path_y_difference is not None else np.nan,
|
|
291
|
+
'Mean_Depth_Distance_Microns': mean_vertical_distance_scaled,
|
|
292
|
+
'Median_Depth_Distance_Microns': median_vertical_distance_scaled,
|
|
293
|
+
'Max_Depth_Distance_Microns': max_vertical_distance_roi_scaled,
|
|
294
|
+
'Microns_per_Pixel': pixel_size_micron_per_px
|
|
295
|
+
}
|
|
296
|
+
df_columns = ['Row', 'Row_Sums', 'Row_Sum_Relative_Count', 'Microns_per_Pixel',
|
|
297
|
+
'Surface_Path_Difference_Microns', 'Mean_Depth_Distance_Microns', 'Median_Depth_Distance_Microns', 'Max_Depth_Distance_Microns']
|
|
298
|
+
df = pd.DataFrame(df_data, columns=df_columns)
|
|
299
|
+
|
|
300
|
+
df.to_csv(output_profile_path, index=False, float_format='%.4f')
|
|
301
|
+
status_label.value = f"Mask & profile (with vertical distances) saved for {last_loaded_stem}."
|
|
302
|
+
except Exception as e: status_label.value = f"ERROR saving profile CSV: {e}"; traceback.print_exc()
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
summary_filename = f"{last_loaded_stem}_Summary.txt"
|
|
306
|
+
output_summary_path = last_loaded_directory / summary_filename
|
|
307
|
+
with open(output_summary_path, 'w') as f:
|
|
308
|
+
f.write(f"Image Analysis Summary\n")
|
|
309
|
+
f.write(f"Original File Stem: {last_loaded_stem}\n")
|
|
310
|
+
f.write(f"Processing Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
311
|
+
f.write("-" * 30 + "\n")
|
|
312
|
+
f.write("Alignment Path Statistics:\n")
|
|
313
|
+
f.write(f" Path Min Y: {path_min_y:.2f} px\n" if path_min_y is not None else " Path Min Y: Not available\n")
|
|
314
|
+
f.write(f" Path Max Y: {path_max_y:.2f} px\n" if path_max_y is not None else " Path Max Y: Not available\n")
|
|
315
|
+
f.write("-" * 30 + "\n")
|
|
316
|
+
f.write("Scaling Factors:\n")
|
|
317
|
+
f.write(f" Real Core Size Input: {real_core_size_cm:.3f} cm\n")
|
|
318
|
+
f.write(f" Core Width Input: {core_width_in_px:.1f} px\n")
|
|
319
|
+
f.write(f" Pixel Size Calculated: {pixel_size_micron_per_px:.6f} micron/px\n")
|
|
320
|
+
f.write("-" * 30 + "\n")
|
|
321
|
+
f.write(f"ROI Vertical Distances (Processed Region: {roi_depth_info_str}):\n")
|
|
322
|
+
f.write(f"Mean Vertical Distance Px: {mean_vertical_distance_roi}):\n")
|
|
323
|
+
f.write(f"Median Vertical Distance Px: {median_vertical_distance_roi}):\n")
|
|
324
|
+
f.write(f"Max Vertical Distance Px: {max_vertical_distance_roi}):\n")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
print(f"Summary data saved to: {output_summary_path}")
|
|
329
|
+
status_label.value = f"Results saved for {last_loaded_stem}."
|
|
330
|
+
except Exception as e: status_label.value = f"ERROR saving summary TXT: {e}"; traceback.print_exc()
|
|
331
|
+
|
|
332
|
+
def reset_viewer_state(called_internally=False):
|
|
333
|
+
global viewer, status_label, confirmed_y_depth
|
|
334
|
+
global path_min_y, path_max_y, path_y_difference # Access path globals
|
|
335
|
+
# Access depth determination widgets if they are global
|
|
336
|
+
global depth_slider, confirm_depth_button, determine_depth_button
|
|
337
|
+
|
|
338
|
+
if not called_internally:
|
|
339
|
+
status_label.value = "Resetting viewer state..."
|
|
340
|
+
try:
|
|
341
|
+
if 'aligned_image' in viewer.layers: viewer.layers.remove('aligned_image')
|
|
342
|
+
if 'thresholded_mask' in viewer.layers: viewer.layers.remove('thresholded_mask')
|
|
343
|
+
if depth_line_layer_name in viewer.layers: viewer.layers.remove(depth_line_layer_name)
|
|
344
|
+
|
|
345
|
+
if 'original_image' in viewer.layers: viewer.layers['original_image'].visible = True
|
|
346
|
+
clear_alignment_paths() # Keep its own status update or make it fully silent
|
|
347
|
+
|
|
348
|
+
confirmed_y_depth = None
|
|
349
|
+
path_min_y, path_max_y, path_y_difference = None, None, None # Reset path stats
|
|
350
|
+
|
|
351
|
+
if 'depth_slider' in globals() and depth_slider is not None and isinstance(depth_slider, widgets.Widget):
|
|
352
|
+
depth_slider.visible = False
|
|
353
|
+
if 'confirm_depth_button' in globals() and confirm_depth_button is not None and isinstance(confirm_depth_button, widgets.Widget):
|
|
354
|
+
confirm_depth_button.visible = False
|
|
355
|
+
if 'determine_depth_button' in globals() and determine_depth_button is not None and isinstance(determine_depth_button, widgets.Widget):
|
|
356
|
+
determine_depth_button.enabled = True
|
|
357
|
+
|
|
358
|
+
if not called_internally:
|
|
359
|
+
status_label.value = "Viewer reset. Ready for next image."
|
|
360
|
+
except Exception as e:
|
|
361
|
+
if not called_internally: status_label.value = f"Error during reset: {e}"
|
|
362
|
+
traceback.print_exc()
|
|
363
|
+
|
|
364
|
+
# --- Functions for Depth Determination ---
|
|
365
|
+
# (start_depth_determination, _update_depth_line, confirm_selected_depth)
|
|
366
|
+
def start_depth_determination():
|
|
367
|
+
global viewer, status_label, depth_slider, confirm_depth_button, determine_depth_button
|
|
368
|
+
status_label.value = "Starting depth determination..."
|
|
369
|
+
if 'aligned_image' not in viewer.layers:
|
|
370
|
+
status_label.value = "ERROR: 'aligned_image' layer not found. Align image first."; return
|
|
371
|
+
try:
|
|
372
|
+
aligned_layer = viewer.layers['aligned_image']; H, W = aligned_layer.data.shape[:2]
|
|
373
|
+
if depth_line_layer_name in viewer.layers: viewer.layers.remove(depth_line_layer_name)
|
|
374
|
+
y_initial = H // 2; line_data = np.array([[[y_initial, 0], [y_initial, W-1]]])
|
|
375
|
+
viewer.add_shapes(data=line_data, shape_type='line', edge_color='lime', edge_width=2,
|
|
376
|
+
name=depth_line_layer_name, ndim=2)
|
|
377
|
+
try:
|
|
378
|
+
idx = viewer.layers.index(depth_line_layer_name); viewer.layers.move(idx, len(viewer.layers)-1)
|
|
379
|
+
except (ValueError, KeyError): pass
|
|
380
|
+
depth_slider.min = 0; depth_slider.max = H - 1; depth_slider.value = y_initial
|
|
381
|
+
depth_slider.visible = True; confirm_depth_button.visible = True
|
|
382
|
+
determine_depth_button.enabled = False
|
|
383
|
+
status_label.value = "Adjust slider for depth, then click 'Confirm Depth Y'."
|
|
384
|
+
except Exception as e: status_label.value = f"Error starting depth determination: {e}"; traceback.print_exc()
|
|
385
|
+
|
|
386
|
+
def _update_depth_line():
|
|
387
|
+
global viewer, depth_slider, status_label
|
|
388
|
+
try:
|
|
389
|
+
if depth_line_layer_name not in viewer.layers or 'aligned_image' not in viewer.layers: return
|
|
390
|
+
line_layer = viewer.layers[depth_line_layer_name]
|
|
391
|
+
aligned_W = viewer.layers['aligned_image'].data.shape[1]; y_new = depth_slider.value
|
|
392
|
+
line_layer.data = [np.array([[y_new, 0], [y_new, aligned_W]])]
|
|
393
|
+
except Exception as e: status_label.value = f"Error updating depth line: {e}"
|
|
394
|
+
|
|
395
|
+
def confirm_selected_depth():
|
|
396
|
+
global viewer, status_label, depth_slider, confirm_depth_button, determine_depth_button, confirmed_y_depth
|
|
397
|
+
confirmed_y_depth = depth_slider.value
|
|
398
|
+
status_label.value = f"Custom depth Y = {confirmed_y_depth} confirmed."
|
|
399
|
+
print(f"Confirmed Y-depth: {confirmed_y_depth}")
|
|
400
|
+
try:
|
|
401
|
+
if depth_line_layer_name in viewer.layers: viewer.layers.remove(depth_line_layer_name)
|
|
402
|
+
except Exception as e: status_label.value = f"Error removing depth line: {e}"
|
|
403
|
+
depth_slider.visible = False; confirm_depth_button.visible = False
|
|
404
|
+
determine_depth_button.enabled = True
|
|
405
|
+
|
|
406
|
+
def manual_trigger_button_clicked():
|
|
407
|
+
global status_label
|
|
408
|
+
print("'Align Image' button clicked.")
|
|
409
|
+
current_image_layer, current_shapes_layer = None, None
|
|
410
|
+
try:
|
|
411
|
+
current_image_layer = viewer.layers['original_image']
|
|
412
|
+
current_shapes_layer = viewer.layers['alignment_path']
|
|
413
|
+
except KeyError as e: status_label.value = f"ERROR: {e}"; return
|
|
414
|
+
if current_shapes_layer.data and len(current_shapes_layer.data) > 0:
|
|
415
|
+
align_image_to_path(current_image_layer, current_shapes_layer, viewer)
|
|
416
|
+
else: status_label.value = "No path drawn on 'alignment_path' layer."
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def F_Analysis_widget():
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
sample_image = data.astronaut()
|
|
423
|
+
image_layer = viewer.add_image(sample_image, name='original_image')
|
|
424
|
+
shapes_layer = viewer.add_shapes(data=None, ndim=2, shape_type='path', edge_width=2,
|
|
425
|
+
edge_color='cyan', face_color='transparent', name='alignment_path')
|
|
426
|
+
shapes_layer.mode = 'add_path'
|
|
427
|
+
print("Automatic alignment on draw DEACTIVATED.")
|
|
428
|
+
|
|
429
|
+
align_button = PushButton(text="Align Image to Last Path")
|
|
430
|
+
align_button.clicked.connect(manual_trigger_button_clicked)
|
|
431
|
+
clear_paths_button = PushButton(text="Clear Alignment Paths")
|
|
432
|
+
clear_paths_button.clicked.connect(clear_alignment_paths)
|
|
433
|
+
load_image_widget = load_new_image
|
|
434
|
+
load_image_widget.label = " "
|
|
435
|
+
|
|
436
|
+
depth_label = Label(value="--- Custom Depth Selection ---")
|
|
437
|
+
depth_slider.changed.connect(_update_depth_line)
|
|
438
|
+
determine_depth_button.clicked.connect(start_depth_determination)
|
|
439
|
+
confirm_depth_button.clicked.connect(confirm_selected_depth)
|
|
440
|
+
|
|
441
|
+
threshold_label = Label(value="--- Thresholding ---")
|
|
442
|
+
auto_threshold_button = PushButton(text="Auto Threshold")
|
|
443
|
+
auto_threshold_button.clicked.connect(calculate_auto_threshold)
|
|
444
|
+
apply_threshold_button = PushButton(text="Apply Threshold")
|
|
445
|
+
apply_threshold_button.clicked.connect(apply_manual_threshold)
|
|
446
|
+
threshold_value_widget.changed.connect(_update_threshold_preview)
|
|
447
|
+
|
|
448
|
+
save_label = Label(value="--- Output ---")
|
|
449
|
+
process_save_button = PushButton(text="Process Image and Save Results") # Renamed for clarity
|
|
450
|
+
process_save_button.clicked.connect(process_and_save)
|
|
451
|
+
reset_button = PushButton(text="Reset for Next Image")
|
|
452
|
+
reset_button.clicked.connect(lambda: reset_viewer_state(called_internally=False))
|
|
453
|
+
|
|
454
|
+
control_container = Container(widgets=[
|
|
455
|
+
load_image_widget, Label(value="--- Alignment ---"), align_button, clear_paths_button,
|
|
456
|
+
depth_label, determine_depth_button, depth_slider, confirm_depth_button,
|
|
457
|
+
threshold_label, auto_threshold_button, threshold_value_widget, apply_threshold_button,
|
|
458
|
+
save_label, real_core_size_widget,
|
|
459
|
+
core_width_px_widget, process_save_button, Label(value="--- General ---"), reset_button,
|
|
460
|
+
status_label
|
|
461
|
+
])
|
|
462
|
+
# Add the container to the viewer
|
|
463
|
+
return control_container
|
|
464
|
+
|
|
465
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
name: fspi_analysis
|
|
2
|
+
display_name: Controls
|
|
3
|
+
visibility: public
|
|
4
|
+
contributions:
|
|
5
|
+
commands:
|
|
6
|
+
- id: fspi_analysis.make_F_Analysis_widget
|
|
7
|
+
python_name: fspi_analysis.fspi_gui:F_Analysis_widget
|
|
8
|
+
title: Make F Analysis Widget
|
|
9
|
+
widgets:
|
|
10
|
+
- command: fspi_analysis.make_F_Analysis_widget
|
|
11
|
+
display_name: FSPI Image Analysis
|
|
12
|
+
|