Skip to content

Commit f618f7f

Browse files
authored
Merge pull request #106 from fractal-analytics-platform/91_off_by_one
Update ROIs
2 parents 8f6ebb0 + f81cd37 commit f618f7f

File tree

6 files changed

+186
-24
lines changed

6 files changed

+186
-24
lines changed

fractal_tasks_core/create_zarr_structure.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,10 @@
3434

3535

3636
def define_omero_channels(actual_channels, channel_parameters, bit_depth):
37-
from devtools import debug
3837

3938
omero_channels = []
4039
default_colormaps = ["00FFFF", "FF00FF", "FFFF00"]
4140
for channel in actual_channels:
42-
debug(channel_parameters[channel])
4341

4442
# Set colormap. If missing, use the default ones (for the first three
4543
# channels) or gray
@@ -64,7 +62,6 @@ def define_omero_channels(actual_channels, channel_parameters, bit_depth):
6462
},
6563
}
6664
)
67-
debug(omero_channels[-1])
6865

6966
try:
7067
omero_channels[-1]["window"]["start"] = channel_parameters[

fractal_tasks_core/lib_regions_of_interest.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,11 @@ def prepare_FOV_ROI_table(
2828
# when creating AnnData object.
2929
# Do this in the beginning to allow concatenation with e.g. time
3030
df.index = df.index.astype(str)
31-
from devtools import debug
3231

33-
debug(df)
34-
# Calculate bounding box extents in physical units
35-
36-
for mu in ["x", "y", "z"]:
37-
38-
# Reset reference values for coordinates
39-
df[f"{mu}_micrometer"] -= df[f"{mu}_micrometer"].min()
40-
41-
# Obtain box size in physical units
42-
df[f"len_{mu}_micrometer"] = df[f"{mu}_pixel"] * df[f"pixel_size_{mu}"]
32+
# Obtain box size in physical units
33+
df = df.assign(len_x_micrometer=df.x_pixel * df.pixel_size_x)
34+
df = df.assign(len_y_micrometer=df.y_pixel * df.pixel_size_y)
35+
df = df.assign(len_z_micrometer=df.z_pixel * df.pixel_size_z)
4336

4437
# Select only the numeric positional columns needed to define ROIs
4538
# (to avoid) casting things like the data column to float32
@@ -51,6 +44,8 @@ def prepare_FOV_ROI_table(
5144
"len_x_micrometer",
5245
"len_y_micrometer",
5346
"len_z_micrometer",
47+
"x_micrometer_original",
48+
"y_micrometer_original",
5449
]
5550

5651
# Assign dtype explicitly, to avoid
@@ -172,6 +167,16 @@ def convert_ROI_table_to_indices(
172167
level: int = 0,
173168
coarsening_xy: int = 2,
174169
full_res_pxl_sizes_zyx: Iterable[float] = None,
170+
cols_xyz_pos: Iterable[str] = [
171+
"x_micrometer",
172+
"y_micrometer",
173+
"z_micrometer",
174+
],
175+
cols_xyz_len: Iterable[str] = [
176+
"len_x_micrometer",
177+
"len_y_micrometer",
178+
"len_z_micrometer",
179+
],
175180
) -> List[List[int]]:
176181

177182
# Set pyramid-level pixel sizes
@@ -180,16 +185,23 @@ def convert_ROI_table_to_indices(
180185
pxl_size_x *= prefactor
181186
pxl_size_y *= prefactor
182187

188+
x_pos, y_pos, z_pos = cols_xyz_pos[:]
189+
x_len, y_len, z_len = cols_xyz_len[:]
190+
191+
origin_x = min(ROI[:, x_pos].X[:, 0])
192+
origin_y = min(ROI[:, y_pos].X[:, 0])
193+
origin_z = min(ROI[:, z_pos].X[:, 0])
194+
183195
list_indices = []
184196
for FOV in ROI.obs_names:
185197

186198
# Extract data from anndata table
187-
x_micrometer = ROI[FOV, "x_micrometer"].X[0, 0]
188-
y_micrometer = ROI[FOV, "y_micrometer"].X[0, 0]
189-
z_micrometer = ROI[FOV, "z_micrometer"].X[0, 0]
190-
len_x_micrometer = ROI[FOV, "len_x_micrometer"].X[0, 0]
191-
len_y_micrometer = ROI[FOV, "len_y_micrometer"].X[0, 0]
192-
len_z_micrometer = ROI[FOV, "len_z_micrometer"].X[0, 0]
199+
x_micrometer = ROI[FOV, x_pos].X[0, 0] - origin_x
200+
y_micrometer = ROI[FOV, y_pos].X[0, 0] - origin_y
201+
z_micrometer = ROI[FOV, z_pos].X[0, 0] - origin_z
202+
len_x_micrometer = ROI[FOV, x_len].X[0, 0]
203+
len_y_micrometer = ROI[FOV, y_len].X[0, 0]
204+
len_z_micrometer = ROI[FOV, z_len].X[0, 0]
193205

194206
# Identify indices along the three dimensions
195207
start_x = x_micrometer / pxl_size_x
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
def is_overlapping_1D(line1, line2, tol=0):
2+
"""
3+
Based on https://stackoverflow.com/a/70023212/19085332
4+
5+
line:
6+
(xmin, xmax)
7+
"""
8+
return line1[0] <= line2[1] - tol and line2[0] <= line1[1] - tol
9+
10+
11+
def is_overlapping_2D(box1, box2, tol=0):
12+
"""
13+
Based on https://stackoverflow.com/a/70023212/19085332
14+
15+
box:
16+
(xmin, ymin, xmax, ymax)
17+
"""
18+
overlap_x = is_overlapping_1D(
19+
[box1[0], box1[2]], [box2[0], box2[2]], tol=tol
20+
)
21+
overlap_y = is_overlapping_1D(
22+
[box1[1], box1[3]], [box2[1], box2[3]], tol=tol
23+
)
24+
return overlap_x and overlap_y
25+
26+
27+
def get_overlapping_pair(tmp_df, tol=0):
28+
# NOTE: here we use positional indices (i.e. starting from 0)
29+
num_lines = len(tmp_df.index)
30+
for pos_ind_1 in range(num_lines):
31+
for pos_ind_2 in range(pos_ind_1):
32+
if is_overlapping_2D(
33+
tmp_df.iloc[pos_ind_1], tmp_df.iloc[pos_ind_2], tol=tol
34+
):
35+
return (pos_ind_1, pos_ind_2)
36+
return False
37+
38+
39+
def remove_FOV_overlaps(df):
40+
41+
# Set tolerance (this should be much smaller than pixel size or expected
42+
# round-offs), and maximum number of iterations in constraint solver
43+
tol = 1e-10
44+
max_iterations = 200
45+
46+
# Create temporary columns (to streamline overlap removals), which are
47+
# then removed at the end of the remove_FOV_overlaps function
48+
df["xmin"] = df["x_micrometer"]
49+
df["ymin"] = df["y_micrometer"]
50+
df["xmax"] = df["x_micrometer"] + df["pixel_size_x"] * df["x_pixel"]
51+
df["ymax"] = df["y_micrometer"] + df["pixel_size_y"] * df["y_pixel"]
52+
list_columns = ["xmin", "ymin", "xmax", "ymax"]
53+
54+
# Create columns with the original positions (not to be removed)
55+
df["x_micrometer_original"] = df["x_micrometer"]
56+
df["y_micrometer_original"] = df["y_micrometer"]
57+
58+
# Check that tolerance is much smaller than pixel sizes
59+
min_pixel_size = df[["pixel_size_x", "pixel_size_y"]].min().min()
60+
if tol > min_pixel_size / 1e3:
61+
raise Exception(
62+
f"In remove_FOV_overlaps, {tol=} but {min_pixel_size=}"
63+
)
64+
65+
# Loop over wells
66+
wells = sorted(list(set([ind[0] for ind in df.index])))
67+
for well in wells:
68+
print(f"[remove_FOV_overlaps] removing FOV overlaps for {well=}")
69+
70+
# NOTE: these are positional indices (i.e. starting from 0)
71+
pair_pos_indices = get_overlapping_pair(
72+
df.loc[well][list_columns], tol=tol
73+
)
74+
75+
# Keep going until there are no overlaps, or until iteration reaches
76+
# max_iterations
77+
iteration = 0
78+
while pair_pos_indices:
79+
iteration += 1
80+
81+
# Identify overlapping FOVs
82+
pos_ind_1, pos_ind_2 = pair_pos_indices
83+
fov_id_1 = df.loc[well].index[pos_ind_1]
84+
fov_id_2 = df.loc[well].index[pos_ind_2]
85+
xmin_1, ymin_1, xmax_1, ymax_1 = df.loc[well][list_columns].iloc[
86+
pos_ind_1
87+
]
88+
xmin_2, ymin_2, xmax_2, ymax_2 = df.loc[well][list_columns].iloc[
89+
pos_ind_2
90+
]
91+
92+
print(
93+
f"[remove_FOV_overlaps] {iteration=}, removing overlap between"
94+
f" {fov_id_1=} and {fov_id_2=}"
95+
)
96+
97+
# Two overlapping FOVs MUST share either a vertical boundary or an
98+
# horizontal one
99+
is_x_equal = abs(xmin_1 - xmin_2) < tol and (xmax_1 - xmax_2) < tol
100+
is_y_equal = abs(ymin_1 - ymin_2) < tol and (ymax_1 - ymax_2) < tol
101+
if is_x_equal + is_y_equal != 1:
102+
raise Exception(
103+
"Two overlapping FOVs MUST share either a "
104+
"vertical boundary or an horizontal one, but "
105+
f"{is_x_equal=} and {is_y_equal=}"
106+
)
107+
108+
# Compute and apply shift (either along x or y)
109+
if is_y_equal:
110+
min_max_x = min(xmax_1, xmax_2)
111+
max_min_x = max(xmin_1, xmin_2)
112+
shift_x = min_max_x - max_min_x
113+
ind = df.loc[well].loc[:, "xmin"] >= max_min_x - tol
114+
if not (shift_x > 0.0 and ind.to_numpy().max() > 0):
115+
raise Exception(
116+
"Something wrong in remove_FOV_overlaps\n"
117+
f"{shift_x=}\n"
118+
f"{ind.to_numpy()=}"
119+
)
120+
df.loc[well].loc[ind, "xmin"] += shift_x
121+
df.loc[well].loc[ind, "xmax"] += shift_x
122+
df.loc[well].loc[ind, "x_micrometer"] += shift_x
123+
if is_x_equal:
124+
min_max_y = min(ymax_1, ymax_2)
125+
max_min_y = max(ymin_1, ymin_2)
126+
shift_y = min_max_y - max_min_y
127+
ind = df.loc[well].loc[:, "ymin"] >= max_min_y - tol
128+
if not (shift_y > 0.0 and ind.to_numpy().max() > 0):
129+
raise Exception(
130+
"Something wrong in remove_FOV_overlaps\n"
131+
f"{shift_y=}\n"
132+
f"{ind.to_numpy()=}"
133+
)
134+
df.loc[well].loc[ind, "ymin"] += shift_y
135+
df.loc[well].loc[ind, "ymax"] += shift_y
136+
df.loc[well].loc[ind, "y_micrometer"] += shift_y
137+
138+
pair_pos_indices = get_overlapping_pair(
139+
df.loc[well][list_columns], tol=tol
140+
)
141+
if iteration > max_iterations:
142+
raise Exception("Something went wrong, {num_iteration=}")
143+
df.drop(list_columns, axis=1, inplace=True)
144+
145+
return df

fractal_tasks_core/metadata_parsing.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import pandas as pd
1717
from defusedxml import ElementTree
1818

19+
from .lib_remove_FOV_overlaps import remove_FOV_overlaps
20+
1921

2022
def parse_yokogawa_metadata(mrf_path, mlf_path):
2123
"""
@@ -77,6 +79,8 @@ def parse_yokogawa_metadata(mrf_path, mlf_path):
7779
# Maybe return it here for further checks and produce a warning if it does
7880
# not match
7981

82+
site_metadata = remove_FOV_overlaps(site_metadata)
83+
8084
return site_metadata, total_files
8185

8286

tests/test_unit_ROI_indices.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ def get_metadata_dataframe():
2727
"""
2828
Create artificial metadata dataframe
2929
"""
30-
df = pd.DataFrame(np.zeros((4, 11)), dtype=int)
30+
df = pd.DataFrame(np.zeros((4, 13)), dtype=float)
3131
df.index = FOV_IDS
3232
df.columns = [
3333
"x_micrometer",
3434
"y_micrometer",
3535
"z_micrometer",
36+
"x_micrometer_original",
37+
"y_micrometer_original",
3638
"x_pixel",
3739
"y_pixel",
3840
"z_pixel",
@@ -56,6 +58,8 @@ def get_metadata_dataframe():
5658
img_size_y_micrometer,
5759
img_size_y_micrometer,
5860
]
61+
df["x_micrometer_original"] = df["x_micrometer"]
62+
df["y_micrometer_original"] = df["y_micrometer"]
5963
df["z_micrometer"] = [0.0, 0.0, 0.0, 0.0]
6064
df["x_pixel"] = [IMG_SIZE_X] * 4
6165
df["y_pixel"] = [IMG_SIZE_Y] * 4

tests/test_unit_parse_yokogawa_metadata.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
mlf_path_1 = f"{path}MeasurementData_SingleWell2Sites_MultiZ.mlf"
2828
mrf_path_1 = f"{path}MeasurementDetail.mrf"
2929
expected_files_1 = 4
30-
expected_shape_1 = (2, 11)
30+
expected_shape_1 = (2, 13)
3131
x_mic_pos_1 = (-1448.3, -1032.3)
3232
y_mic_pos_1 = -1517.7
3333
pixel_size_z_1 = 1.0
@@ -46,7 +46,7 @@
4646
mlf_path_2 = f"{path}MeasurementData_2x2_well.mlf"
4747
mrf_path_2 = f"{path}MeasurementDetail_2x2_well.mrf"
4848
expected_files_2 = 120
49-
expected_shape_2 = (4, 11)
49+
expected_shape_2 = (4, 13)
5050
x_mic_pos_2 = (-1448.3, -1032.3)
5151
y_mic_pos_2 = (-1517.7, -1166.7)
5252
pixel_size_z_2 = 1.0
@@ -62,7 +62,7 @@
6262
mlf_path_3 = f"{path}MeasurementData_SingleWell2Sites_SingleZ.mlf"
6363
mrf_path_3 = f"{path}MeasurementDetail.mrf"
6464
expected_files_3 = 2
65-
expected_shape_3 = (2, 11)
65+
expected_shape_3 = (2, 13)
6666
x_mic_pos_3 = (-1448.3, -1032.3)
6767
y_mic_pos_3 = -1517.7
6868
pixel_size_z_3 = 1.0

0 commit comments

Comments
 (0)