• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

angelolab / toffy / 11961352837

21 Nov 2024 08:44PM CUT coverage: 91.952% (-0.2%) from 92.104%
11961352837

Pull #467

github

web-flow
Merge 25e0126a9 into 5f16d6fa0
Pull Request #467: Enforce `exact_match=True` when listing JSON file for `get_estimated_time` for MPH

1421 of 1549 branches covered (91.74%)

Branch coverage included in aggregate %.

8 of 8 new or added lines in 4 files covered. (100.0%)

6 existing lines in 1 file now uncovered.

2715 of 2949 relevant lines covered (92.07%)

0.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.24
/src/toffy/tiling_utils.py
1
import copy
1✔
2
import os
1✔
3
import re
1✔
4
import warnings
1✔
5
from dataclasses import dataclass
1✔
6
from datetime import datetime
1✔
7
from itertools import combinations, product
1✔
8
from operator import itemgetter
1✔
9
from typing import Iterable, Optional, Tuple
1✔
10

11
import ipywidgets as widgets
1✔
12
import matplotlib.pyplot as plt
1✔
13
import numpy as np
1✔
14
import pandas as pd
1✔
15
from alpineer import misc_utils
1✔
16
from IPython.display import display
1✔
17
from matplotlib.patches import Patch, Rectangle
1✔
18
from skimage.draw import ellipse
1✔
19
from sklearn.linear_model import LinearRegression
1✔
20
from sklearn.utils import shuffle
1✔
21

22
from toffy import json_utils, settings
1✔
23

24

25
def assign_metadata_vals(input_dict, output_dict, keys_ignore):
1✔
26
    """Copy the `str`, `int`, `float`, and `bool` metadata keys of
27
    `input_dict` over to `output_dict`, ignoring `keys_ignore` metadata keys.
28

29
    Args:
30
        input_dict (dict):
31
            The `dict` to copy the metadata values over from
32
        output_dict (dict):
33
            The `dict` to copy the metadata values into.
34
            Note that if a metadata key name in `input_dict` exists in `output_dict`,
35
            the latter's will get overwritten
36
        keys_ignore (list):
37
            The list of keys in `input_dict` to ignore
38

39
    Returns:
40
        dict:
41
            `output_dict` with the valid `metadata_keys` from `input_dict` copied over
42
    """
43
    # get the metadata values to copy over
44
    metadata_keys = list(input_dict.keys())
1✔
45

46
    # remove anything set in keys_ignore
47
    for ki in keys_ignore:
1✔
48
        if ki in input_dict:
1✔
49
            metadata_keys.remove(ki)
1✔
50

51
    # assign over the remaining metadata keys
52
    for mk in metadata_keys:
1✔
53
        if type(input_dict[mk]) in [str, int, float, bool, type(None)]:
1✔
54
            output_dict[mk] = input_dict[mk]
1✔
55

56
    return output_dict
1✔
57

58

59
def verify_x_coordinate_on_slide(coord_val: int, coord_type: Optional[str] = "optical") -> bool:
1✔
60
    """Verify that the x-coordinate lies in the accepted slide boundary.
61

62
    Args:
63
        coord_val (int):
64
            The x-coordinate value to validate
65
        coord_type (Optional[str]):
66
            Indicates if the coordinate is optical pixels, stage coordinates,
67
            or stage microns
68

69
    Returns:
70
        bool:
71
            Whether the x-coordinate is in bounds or not
72
    """
73
    if coord_type not in ["optical", "stage", "micron"]:
1✔
74
        raise ValueError("Invalid x coord_type specified: must be 'optical', 'stage', or 'micron'")
1✔
75

76
    if coord_type == "optical":
1✔
77
        if (
1✔
78
            coord_val < settings.OPTICAL_LEFT_BOUNDARY - settings.OPTICAL_BOUNDARY_TOL
79
            or coord_val > settings.OPTICAL_RIGHT_BOUNDARY + settings.OPTICAL_BOUNDARY_TOL
80
        ):
81
            return False
1✔
82
    elif coord_type == "stage":
1✔
83
        if (
1✔
84
            coord_val < settings.STAGE_LEFT_BOUNDARY - settings.STAGE_BOUNDARY_TOL
85
            or coord_val > settings.STAGE_RIGHT_BOUNDARY + settings.STAGE_BOUNDARY_TOL
86
        ):
87
            return False
1✔
88
    elif coord_type == "micron":
1✔
89
        coord_val_conv = (
1✔
90
            coord_val * settings.MICRON_TO_STAGE_X_MULTIPLIER - settings.MICRON_TO_STAGE_X_OFFSET
91
        )
92

93
        if (
1✔
94
            coord_val_conv < settings.STAGE_LEFT_BOUNDARY - settings.STAGE_BOUNDARY_TOL
95
            or coord_val_conv > settings.STAGE_RIGHT_BOUNDARY + settings.STAGE_BOUNDARY_TOL
96
        ):
97
            return False
1✔
98

99
    return True
1!
100

101

102
def verify_y_coordinate_on_slide(coord_val: int, coord_type: Optional[str] = "optical") -> bool:
1✔
103
    """Verify that the y-coordinate lies in the accepted slide boundary.
104

105
    Args:
106
        coord_val (int):
107
            The x-coordinate value to validate
108
        coord_type (Optional[str]):
109
            Indicates if the coordinate is optical pixels, stage coordinates,
110
            or stage microns
111

112
    Returns:
113
        bool:
114
            Whether the y-coordinate is in bounds or not
115
    """
116
    if coord_type not in ["optical", "stage", "micron"]:
1✔
117
        raise ValueError("Invalid y coord_type specified: must be 'optical', 'stage', or 'micron'")
1✔
118

119
    # NOTE: stage coordinates increase from bottom to top, vice versa for optical coordinates
120
    if coord_type == "optical":
1✔
121
        if (
1✔
122
            coord_val < settings.OPTICAL_TOP_BOUNDARY - settings.OPTICAL_BOUNDARY_TOL
123
            or coord_val > settings.OPTICAL_BOTTOM_BOUNDARY + settings.OPTICAL_BOUNDARY_TOL
124
        ):
125
            return False
1✔
126
    elif coord_type == "stage":
1✔
127
        if (
1✔
128
            coord_val > settings.STAGE_TOP_BOUNDARY + settings.STAGE_BOUNDARY_TOL
129
            or coord_val < settings.STAGE_BOTTOM_BOUNDARY - settings.STAGE_BOUNDARY_TOL
130
        ):
131
            return False
1✔
132
    elif coord_type == "micron":
1✔
133
        coord_val_conv = (
1✔
134
            coord_val * settings.MICRON_TO_STAGE_Y_MULTIPLIER - settings.MICRON_TO_STAGE_Y_OFFSET
135
        )
136

137
        if (
1✔
138
            coord_val_conv > settings.STAGE_TOP_BOUNDARY + settings.STAGE_BOUNDARY_TOL
139
            or coord_val_conv < settings.STAGE_BOTTOM_BOUNDARY - settings.STAGE_BOUNDARY_TOL
140
        ):
141
            return False
1✔
142

143
    return True
1!
144

145

146
def verify_coordinate_on_slide(
1✔
147
    coord_val: Iterable[Tuple[float, float]], coord_type: Optional[str] = "optical"
148
) -> bool:
149
    """Verify that the coordinate lies in the accepted slide boundary.
150

151
    Args:
152
        coord_val (Iterable[Tuple[float, float]]):
153
            The coordinate to validate
154
        coord_type (Optional[str]):
155
            Indicates if the coordinate is optical pixels, stage coordinates,
156
            or stage microns
157

158
    Returns:
159
        bool:
160
            Whether the coordinate is in bounds or not
161
    """
162
    if coord_type not in ["optical", "stage", "micron"]:
1✔
163
        raise ValueError("Invalid coord_type specified: must be 'optical', 'stage', or 'micron'")
1✔
164

165
    return verify_x_coordinate_on_slide(coord_val[0], coord_type) and verify_y_coordinate_on_slide(
1✔
166
        coord_val[1], coord_type
167
    )
168

169

170
def read_tiling_param(prompt, error_msg, cond, dtype):
1✔
171
    """A helper function to read a tiling param from a user prompt.
172

173
    Args:
174
        prompt (str):
175
            The initial text to display to the user
176
        error_msg (str):
177
            The message to display if an invalid input is entered
178
        cond (function):
179
            What defines valid input for the variable
180
        dtype (type):
181
            The type of variable to read
182

183
    Returns:
184
        Union([int, float, str]):
185
            The value entered by the user
186
    """
187
    # ensure the dtype is valid
188
    misc_utils.verify_in_list(provided_dtype=dtype, acceptable_dtypes=[int, float, str])
1✔
189

190
    while True:
1✔
191
        # read in the variable with correct dtype
192
        # print error message and re-prompt if cannot be coerced
193
        try:
1✔
194
            var = dtype(input(prompt))
1✔
195
        except ValueError:
1✔
196
            print(error_msg)
1✔
197
            continue
1✔
198

199
        # if condition passes, return
200
        if cond(var):
1✔
201
            return var
1✔
202

203
        # otherwise, print the error message and re-prompt
204
        print(error_msg)
1✔
205

206

207
def read_fiducial_info():
1✔
208
    """Prompt the user to input the fiducial info (in both stage and optical scoordinates).
209

210
    Returns:
211
        dict:
212
            Contains the stage and optical coordinates of all 6 required fiducials
213
    """
214
    # define the dict to fill in
215
    fiducial_info = {}
1✔
216

217
    # store the stage and optical coordinates in separate keys
218
    fiducial_info["stage"] = {}
1✔
219
    fiducial_info["optical"] = {}
1✔
220

221
    # read the stage and optical coordinate for each position
222
    for pos in settings.FIDUCIAL_POSITIONS:
1✔
223
        stage_x = read_tiling_param(
1✔
224
            "Enter the stage x-coordinate of the %s fiducial: " % pos,
225
            "Error: stage x-coordinate must be a numeric value in slide range: [%.2f, %.2f]"
226
            % (
227
                settings.STAGE_LEFT_BOUNDARY - settings.STAGE_BOUNDARY_TOL,
228
                settings.STAGE_RIGHT_BOUNDARY + settings.STAGE_BOUNDARY_TOL,
229
            ),
230
            lambda fc: verify_x_coordinate_on_slide(fc, "stage"),
231
            dtype=float,
232
        )
233

234
        stage_y = read_tiling_param(
1✔
235
            "Enter the stage y-coordinate of the %s fiducial: " % pos,
236
            "Error: stage y-coordinate must be a numeric value in slide range: [%.2f, %.2f]"
237
            % (
238
                settings.STAGE_BOTTOM_BOUNDARY - settings.STAGE_BOUNDARY_TOL,
239
                settings.STAGE_TOP_BOUNDARY + settings.STAGE_BOUNDARY_TOL,
240
            ),
241
            lambda fc: verify_y_coordinate_on_slide(fc, "stage"),
242
            dtype=float,
243
        )
244

245
        optical_x = read_tiling_param(
1✔
246
            "Enter the optical x-coordinate of the %s fiducial: " % pos,
247
            "Error: optical x-coordinate must be a numeric value in slide range: [%.2f, %.2f]"
248
            % (
249
                settings.OPTICAL_LEFT_BOUNDARY - settings.OPTICAL_BOUNDARY_TOL,
250
                settings.OPTICAL_RIGHT_BOUNDARY + settings.OPTICAL_BOUNDARY_TOL,
251
            ),
252
            lambda fc: verify_x_coordinate_on_slide(fc, "optical"),
253
            dtype=float,
254
        )
255

256
        optical_y = read_tiling_param(
1✔
257
            "Enter the optical y-coordinate of the %s fiducial: " % pos,
258
            "Error: optical y-coordinate must be a numeric value in slide range: [%.2f, %.2f]"
259
            % (
260
                settings.OPTICAL_TOP_BOUNDARY - settings.OPTICAL_BOUNDARY_TOL,
261
                settings.OPTICAL_BOTTOM_BOUNDARY + settings.OPTICAL_BOUNDARY_TOL,
262
            ),
263
            lambda fc: verify_y_coordinate_on_slide(fc, "optical"),
264
            dtype=float,
265
        )
266

267
        # define a new stage entry for the fiducial position
268
        fiducial_info["stage"][pos] = {"x": stage_x, "y": stage_y}
1✔
269

270
        # ditto for optical
271
        fiducial_info["optical"][pos] = {"x": optical_x, "y": optical_y}
1✔
272

273
    return fiducial_info
1✔
274

275

276
def verify_coreg_param_tolerance(coreg_params: dict, tol: Optional[float] = 0.2):
1✔
277
    """Verify that the coreg params lie within acceptable range.
278

279
    Args:
280
        coreg_params (dict):
281
            Contains all of the co-registration parameters
282
        tol (Optional[float]):
283
            How far away from the baseline is acceptable for co-registration
284

285
    Raises:
286
        ValueError:
287
            If one of the co-registration parameters fails the tolerance test
288
    """
289
    # run for each parameter
290
    params_list = [
1✔
291
        "STAGE_TO_OPTICAL_X_MULTIPLIER",
292
        "STAGE_TO_OPTICAL_X_OFFSET",
293
        "STAGE_TO_OPTICAL_Y_MULTIPLIER",
294
        "STAGE_TO_OPTICAL_Y_OFFSET",
295
    ]
296

297
    for param in params_list:
1✔
298
        # retrieve the baseline val
299
        baseline_val = settings.COREG_PARAM_BASELINE[param]
1✔
300

301
        # get the left and right boundaries
302
        left_extreme = baseline_val - abs(baseline_val * tol)
1✔
303
        right_extreme = baseline_val + abs(baseline_val * tol)
1✔
304

305
        if coreg_params[param] < left_extreme or coreg_params[param] > right_extreme:
1✔
306
            raise ValueError(
1✔
307
                "coreg_param %s is out of range ([%.2f, %.2f], got %.2f): "
308
                "please re-run co-registration"
309
                % (param, left_extreme, right_extreme, coreg_params[param])
310
            )
311

312

313
def generate_coreg_params(fiducial_info):
1✔
314
    """Use linear regression from fiducial stage to optical coordinates to define
315
    co-registration params.
316

317
    Separate regressions for x and y values.
318

319
    Args:
320
        fiducial_info (dict):
321
            The stage and optical coordinates of each fiducial, created by `read_fiducial_info`
322

323
    Returns:
324
        dict:
325
            Contains the new multiplier and offset along the x- and y-axes
326
    """
327
    # define the dict to fill in
328
    coreg_params = {}
1✔
329

330
    # extract the data for for x-coordinate stage to optical regression
331
    x_stage = np.array(
1✔
332
        [fiducial_info["stage"][pos]["x"] for pos in settings.FIDUCIAL_POSITIONS]
333
    ).reshape(-1, 1)
334
    x_optical = np.array(
1✔
335
        [fiducial_info["optical"][pos]["x"] for pos in settings.FIDUCIAL_POSITIONS]
336
    ).reshape(-1, 1)
337

338
    # generate x regression params
339
    x_reg = LinearRegression().fit(x_stage, x_optical)
1✔
340

341
    # add the multiplier and offset params for x
342
    x_multiplier = x_reg.coef_[0][0]
1✔
343
    x_offset = x_reg.intercept_[0] / x_multiplier
1✔
344
    coreg_params["STAGE_TO_OPTICAL_X_MULTIPLIER"] = x_multiplier
1✔
345
    coreg_params["STAGE_TO_OPTICAL_X_OFFSET"] = x_offset
1✔
346

347
    # extract the data for for y-coordinate stage to optical regression
348
    y_stage = np.array(
1✔
349
        [fiducial_info["stage"][pos]["y"] for pos in settings.FIDUCIAL_POSITIONS]
350
    ).reshape(-1, 1)
351
    y_optical = np.array(
1✔
352
        [fiducial_info["optical"][pos]["y"] for pos in settings.FIDUCIAL_POSITIONS]
353
    ).reshape(-1, 1)
354

355
    # generate y regression params
356
    y_reg = LinearRegression().fit(y_stage, y_optical)
1✔
357

358
    # add the multiplier and offset params for y
359
    y_multiplier = y_reg.coef_[0][0]
1✔
360
    y_offset = y_reg.intercept_[0] / y_multiplier
1✔
361
    coreg_params["STAGE_TO_OPTICAL_Y_MULTIPLIER"] = y_multiplier
1✔
362
    coreg_params["STAGE_TO_OPTICAL_Y_OFFSET"] = y_offset
1✔
363

364
    # verify all the parameters generated lie in tolerable range
365
    verify_coreg_param_tolerance(coreg_params)
1✔
366

367
    return coreg_params
1✔
368

369

370
def save_coreg_params(coreg_params, coreg_path=settings.COREG_SAVE_PATH):
1✔
371
    """Save the co-registration parameters to `coreg_params.json` in `toffy`.
372

373
    Args:
374
        coreg_params (dict):
375
            Contains the multiplier and offsets for co-registration along the x- and y-axis
376
        coreg_path (str):
377
            The path to save the co-registration parameters to
378
    """
379
    # generate the time this set of co-registration parameters were generated
380
    coreg_params["date"] = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
1✔
381

382
    # write to a new coreg_params.json file if it doesn't already exist
383
    if not os.path.exists(coreg_path):
1✔
384
        coreg_data = {"coreg_params": [coreg_params]}
1✔
385
        json_utils.write_json_file(json_path=coreg_path, json_object=coreg_data)
1✔
386

387
    # append to the existing coreg_params key if coreg_params.json already exists
388
    else:
389
        coreg_data = json_utils.read_json_file(coreg_path)
1✔
390

391
        coreg_data["coreg_params"].append(coreg_params)
1✔
392

393
        json_utils.write_json_file(json_path=coreg_path, json_object=coreg_data)
1✔
394

395

396
def generate_region_info(region_params):
1✔
397
    """Generate the `region_params` list in the tiling parameter dict.
398

399
    Args:
400
        region_params (dict):
401
            A `dict` mapping each region-specific parameter to a list of values per FOV
402

403
    Returns:
404
        list:
405
            The complete set of `region_params` sorted by region
406
    """
407
    # define the region params list
408
    region_params_list = []
1✔
409

410
    # iterate over all the region parameters, all parameter lists are the same length
411
    for i in range(len(region_params["region_start_row"])):
1✔
412
        # define a dict containing all the region info for the specific FOV
413
        region_info = {rp: region_params[rp][i] for rp in region_params}
1✔
414

415
        # append info to region_params
416
        region_params_list.append(region_info)
1✔
417

418
    return region_params_list
1✔
419

420

421
def read_tiled_region_inputs(region_corners, region_params):
1✔
422
    """Reads input for tiled regions from user and `region_corners`.
423

424
    Updates all the tiling params inplace. Units used are microns.
425

426
    Args:
427
        region_corners (dict):
428
            The data containing the FOVs used to define the upper-left corner of each tiled region
429
        region_params (dict):
430
            A `dict` mapping each region-specific parameter to a list of values per FOV
431
    """
432
    # read in the data for each region (region_start from region_corners_path, others from user)
433
    for fov in region_corners["fovs"]:
1✔
434
        # append the name of the region
435
        region_params["region_name"].append(fov["name"])
1✔
436

437
        # verify if the coordinate is in range
438
        coordinates_in_range = verify_coordinate_on_slide(
1✔
439
            (fov["centerPointMicrons"]["x"], fov["centerPointMicrons"]["y"]), "micron"
440
        )
441

442
        if not coordinates_in_range:
1✔
443
            raise ValueError(
1✔
444
                "Coordinate (%.2f, %.2f) defining ROI %s out of range, "
445
                "check the value specified in region_corners_path"
446
                % (
447
                    fov["centerPointMicrons"]["x"],
448
                    fov["centerPointMicrons"]["y"],
449
                    fov["name"],
450
                )
451
            )
452

453
        # append the starting row and column coordinates
454
        region_params["region_start_row"].append(fov["centerPointMicrons"]["y"])
1✔
455
        region_params["region_start_col"].append(fov["centerPointMicrons"]["x"])
1✔
456

457
        print(
1✔
458
            "Using start coordinates of (%d, %d) in microns for region %s"
459
            % (
460
                fov["centerPointMicrons"]["x"],
461
                fov["centerPointMicrons"]["y"],
462
                fov["name"],
463
            )
464
        )
465

466
        # verify that the micron size specified is valid
467
        if fov["fovSizeMicrons"] <= 0:
1✔
468
            raise ValueError(
1✔
469
                "The fovSizeMicrons field for FOVs in region %s must be positive" % fov["name"]
470
            )
471

472
        print(
1✔
473
            "Using FOV step size of %d microns for both row (y) and column (x) axis of region %s"
474
            % (fov["fovSizeMicrons"], fov["name"])
475
        )
476

477
        # use fovSizeMicrons as the step size along both axes
478
        region_params["row_fov_size"].append(fov["fovSizeMicrons"])
1✔
479
        region_params["col_fov_size"].append(fov["fovSizeMicrons"])
1✔
480

481
        # allow the user to specify the number of fovs along each dimension
482
        num_row = read_tiling_param(
1✔
483
            "Enter the number of FOVs per row for region %s: " % fov["name"],
484
            "Error: number of FOVs per row must be a positive integer",
485
            lambda nx: nx >= 1,
486
            dtype=int,
487
        )
488

489
        num_col = read_tiling_param(
1✔
490
            "Enter the number of FOVs per column for region %s: " % fov["name"],
491
            "Error: number of FOVs per column must be a positive integer",
492
            lambda ny: ny >= 1,
493
            dtype=int,
494
        )
495

496
        region_params["fov_num_row"].append(num_row)
1✔
497
        region_params["fov_num_col"].append(num_col)
1✔
498

499
        # allow the user to specify if the FOVs should be randomized
500
        randomize = read_tiling_param(
1✔
501
            "Randomize FOVs for region %s? Y/N: " % fov["name"],
502
            "Error: randomize parameter must Y or N",
503
            lambda r: r in ["Y", "N", "y", "n"],
504
            dtype=str,
505
        )
506

507
        randomize = randomize.upper()
1✔
508

509
        region_params["region_rand"].append(randomize)
1✔
510

511

512
def set_tiled_region_params(region_corners_path):
1✔
513
    """Given a file specifying top-left FOVs for a set of regions, set the MIBI tiling parameters.
514

515
    User inputs will be required for many values. Units used are microns.
516

517
    Args:
518
        region_corners_path (str):
519
            Path to the JSON file containing the FOVs used to define the upper-left corner
520
            of each tiled region
521

522
    Returns:
523
        dict:
524
            Contains the tiling parameters for each tiled region
525
    """
526
    # file path validation
527
    if not os.path.exists(region_corners_path):
1✔
528
        raise FileNotFoundError(
1✔
529
            "Tiled region corners list file %s does not exist" % region_corners_path
530
        )
531

532
    # read in the region corners data
533
    tiled_region_corners = json_utils.read_json_file(region_corners_path, encoding="utf-8")
1✔
534

535
    tiled_region_corners = json_utils.rename_missing_fovs(tiled_region_corners)
1✔
536

537
    # define the parameter dict to return
538
    tiling_params = {}
1✔
539

540
    # copy over the metadata values from tiled_region_corners to tiling_params
541
    tiling_params = assign_metadata_vals(tiled_region_corners, tiling_params, ["fovs"])
1✔
542

543
    # define the region_params dict
544
    region_params = {rpf: [] for rpf in settings.REGION_PARAM_FIELDS}
1✔
545

546
    # prompt the user for params associated with each tiled region
547
    read_tiled_region_inputs(tiled_region_corners, region_params)
1✔
548

549
    # need to copy fov metadata over, needed for generate_fov_list
550
    tiling_params["fovs"] = copy.deepcopy(tiled_region_corners["fovs"])
1✔
551

552
    # store the read in parameters in the region_params key
553
    tiling_params["region_params"] = generate_region_info(region_params)
1✔
554

555
    # whether to insert moly points between regions
556
    moly_region_insert = read_tiling_param(
1✔
557
        (
558
            "Insert a moly point between each tiled region? "
559
            + "If yes, you must provide a path to the example moly_FOV json file. Y/N: "
560
        ),
561
        "Error: moly point region parameter must be either Y or N",
562
        lambda mri: mri in ["Y", "N", "y", "n"],
563
        dtype=str,
564
    )
565

566
    # convert to uppercase to standardize
567
    moly_region_insert = moly_region_insert.upper()
1✔
568
    tiling_params["moly_region"] = moly_region_insert
1✔
569

570
    # whether to insert moly points between fovs
571
    moly_interval = read_tiling_param(
1✔
572
        (
573
            "Enter the FOV interval size to insert Moly points. If yes, you must provide "
574
            + "a path to the example moly_FOV json file and enter the number of FOVs "
575
            + "between each Moly point. If no, enter 0: "
576
        ),
577
        "Error: moly interval must be 0 or a positive integer",
578
        lambda mi: mi >= 0,
579
        dtype=int,
580
    )
581

582
    if moly_interval > 0:
1✔
583
        tiling_params["moly_interval"] = moly_interval
1✔
584

585
    return tiling_params
1✔
586

587

588
def generate_x_y_fov_pairs(x_range, y_range):
1✔
589
    """Given all x and y coordinates (in microns) a FOV can take,
590
    generate all possible `(x, y)` pairings.
591

592
    Args:
593
        x_range (list):
594
            Range of x values a FOV can take
595
        y_range (list):
596
            Range of y values a FOV can take
597

598
    Returns:
599
        list:
600
            Every possible `(x, y)` pair for a FOV
601
    """
602
    # define a list to hold all the (x, y) pairs
603
    all_pairs = []
1✔
604

605
    # iterate over all combinations of x and y
606
    for t in combinations((x_range, y_range), 2):
1✔
607
        # compute the product of the resulting x and y list pair, append results
608
        for pair in product(t[0], t[1]):
1✔
609
            all_pairs.append(pair)
1✔
610

611
    return all_pairs
1✔
612

613

614
def generate_x_y_fov_pairs_rhombus(
1✔
615
    top_left, top_right, bottom_left, bottom_right, num_row, num_col
616
):
617
    """Generates coordinates (in microns) of FOVs as defined by corners of a rhombus.
618

619
    Args:
620
        top_left (XYCoord): coordinate of top left corner
621
        top_right (XYCoord): coordinate of top right corner
622
        bottom_left (XYCoord): coordinate of bottom right corner
623
        bottom_right (XYCoord): coordiante of bottom right corner
624
        num_row (int): number of fovs on row dimension
625
        num_col (int): number of fovs on column dimension
626

627
    Returns:
628
        list: coordinates for all FOVs defined by region
629
    """
630
    # compute the vertical shift in the top and bottom row of the TMA
631
    top_row_shift = top_right.y - top_left.y
1✔
632
    bottom_row_shift = bottom_right.y - bottom_left.y
1✔
633

634
    # average between the two will be used to increment indices
635
    avg_row_shift = (top_row_shift + bottom_row_shift) / 2
1✔
636

637
    # compute horizontal shift in the left and right column of the TMA
638
    left_col_shift = bottom_left.x - top_left.x
1✔
639
    right_col_shift = bottom_right.x - top_right.x
1✔
640

641
    # average between the two will be used to increment indices
642
    avg_col_shift = (left_col_shift + right_col_shift) / 2
1✔
643

644
    # compute per-FOV adjustment
645
    row_increment = avg_row_shift / (num_col - 1)
1✔
646
    col_increment = avg_col_shift / (num_row - 1)
1✔
647

648
    # compute baseline indices for a rectangle with same coords
649
    row_dif = bottom_left.y - top_left.y
1✔
650
    col_dif = top_right.x - top_left.x
1✔
651

652
    row_baseline = row_dif / (num_row - 1)
1✔
653
    col_baseline = col_dif / (num_col - 1)
1✔
654

655
    pairs = []
1✔
656

657
    for i in range(num_col):
1✔
658
        for j in range(num_row):
1✔
659
            x_coord = top_left.x + col_baseline * i + col_increment * j
1✔
660
            y_coord = top_left.y + row_baseline * j + row_increment * i
1✔
661
            pairs.append((int(x_coord), int(y_coord)))
1✔
662

663
    return pairs
1✔
664

665

666
def generate_tiled_region_fov_list(tiling_params, moly_path: Optional[str] = None):
1✔
667
    """Generate the list of FOVs on the image from the `tiling_params` set for tiled regions.
668

669
    Moly point insertion: happens once every number of FOVs you specified in
670
    `tiled_region_set_params`. There are a couple caveats to keep in mind:
671

672
    - The interval specified will not reset between regions. In other words, if the interval is 3
673
      and the next set of FOVs contains 2 in region 1 and 1 in region 2, the next Moly point will
674
      be placed after the 1st FOV in region 2 (not after the 3rd FOV in region 2). Moly points
675
      inserted between regions are ignored in this calculation.
676
    - If the interval specified cleanly divides the number of FOVs in a region, a Moly point will
677
      not be placed at the end of the region. Suppose 3 FOVs are defined along both the x- and
678
      y-axis for region 1 (for a total of 9 FOVs) and a Moly point FOV interval of 3 is specified.
679
      Without also setting Moly point insertion between different regions, a Moly point will NOT be
680
      placed after the last FOV of region 1 (the next Moly point will appear after the 3rd
681
      FOV in in region 2).
682

683
    Args:
684
        tiling_params (dict):
685
            The tiling parameters created by `set_tiled_region_params`
686
        moly_path (Optional[str]):
687
            The path to the Moly point to insert between FOV intervals and/or regions.
688
            If these insertion parameters are not specified in `tiling_params`, this won't be used.
689
            Defaults to None.
690

691
    Returns:
692
        dict:
693
            Data containing information about each FOV
694
    """
695
    # file path validation
696
    if (tiling_params.get("moly_region", "N") == "Y") or (
1✔
697
        tiling_params.get("moly_interval", 0) > 0
698
    ):
699
        if not os.path.exists(moly_path):
1✔
700
            raise FileNotFoundError(
1✔
701
                "The provided Moly FOV file %s does not exist. If you want                         "
702
                "           to include Moly FOVs you must provide a valid path. Otherwise          "
703
                "                          , select 'No' for the options relating to Moly FOVs"
704
                % moly_path
705
            )
706

707
        # read in the moly point data
708
        moly_point = json_utils.read_json_file(moly_path, encoding="utf-8")
1✔
709

710
    # define the fov_regions dict
711
    fov_regions = {}
1✔
712

713
    # copy over the metadata values from tiling_params to fov_regions
714
    fov_regions = assign_metadata_vals(
1✔
715
        tiling_params, fov_regions, ["region_params", "moly_region", "moly_interval"]
716
    )
717

718
    # define a specific FOVs field in fov_regions, this will contain the actual FOVs
719
    fov_regions["fovs"] = []
1✔
720

721
    # define a counter to determine where to insert a moly point
722
    # only used if moly_interval is set in tiling_params
723
    # NOTE: total_fovs is used to prevent moly_counter from initiating the addition of
724
    # a Moly point at the end of a region
725
    moly_counter = 0
1✔
726
    total_fovs = 0
1✔
727

728
    # iterate through each region and append created fovs to fov_regions['fovs']
729
    for region_index, region_info in enumerate(tiling_params["region_params"]):
1✔
730
        # extract start coordinates
731
        start_row = region_info["region_start_row"]
1✔
732
        start_col = region_info["region_start_col"]
1✔
733

734
        # define the range of x- and y-coordinates to use
735
        row_range = list(range(region_info["fov_num_row"]))
1✔
736
        col_range = list(range(region_info["fov_num_col"]))
1✔
737

738
        # create all pairs between two lists
739
        row_col_pairs = generate_x_y_fov_pairs(row_range, col_range)
1✔
740

741
        # name the FOVs according to MIBI conventions
742
        fov_names = [
1✔
743
            "%s_R%dC%d" % (region_info["region_name"], row + 1, col + 1)
744
            for row in range(region_info["fov_num_row"])
745
            for col in range(region_info["fov_num_col"])
746
        ]
747

748
        # randomize pairs list if specified
749
        if region_info["region_rand"] == "Y":
1✔
750
            # make sure the fov_names are set in the same shuffled indices for renaming
751
            row_col_pairs, fov_names = shuffle(row_col_pairs, fov_names)
1✔
752

753
        # update total_fovs, we'll prevent moly_counter from triggering the appending of
754
        # a Moly point at the end of a region this way
755
        total_fovs += len(row_col_pairs)
1✔
756

757
        for index, (row_i, col_i) in enumerate(row_col_pairs):
1✔
758
            # use the fov size to scale to the current row- and col-coordinate
759
            cur_row = start_row - (row_i * region_info["row_fov_size"])
1✔
760
            cur_col = start_col + (col_i * region_info["col_fov_size"])
1✔
761

762
            # verify that the coordinate generated is in range
763
            if not verify_coordinate_on_slide((cur_col, cur_row), "micron"):
1✔
764
                raise ValueError(
1✔
765
                    "Coordinate (%.2f, %.2f) for FOV %s and ROI %s out of range, "
766
                    "please check the dimensions specified"
767
                    % (cur_col, cur_row, fov_names[index], region_info["region_name"])
768
                )
769

770
            # copy the fov metadata over and add cur_x, cur_y, and name
771
            fov = copy.deepcopy(tiling_params["fovs"][region_index])
1✔
772
            fov["centerPointMicrons"]["x"] = cur_col
1✔
773
            fov["centerPointMicrons"]["y"] = cur_row
1✔
774
            fov["name"] = fov_names[index]
1✔
775

776
            # append value to fov_regions
777
            fov_regions["fovs"].append(fov)
1✔
778

779
            # increment moly_counter as we've added another fov
780
            moly_counter += 1
1✔
781

782
            # append a Moly point if moly_interval is set and we've reached the interval threshold
783
            # the exception: don't insert a Moly point at the end of a region
784
            if (
1✔
785
                "moly_interval" in tiling_params
786
                and moly_counter % tiling_params["moly_interval"] == 0
787
                and moly_counter < total_fovs
788
            ):
789
                fov_regions["fovs"].append(moly_point)
1✔
790

791
        # append Moly point to seperate regions if not last and if specified
792
        if (
1✔
793
            "moly_region" in tiling_params
794
            and tiling_params["moly_region"] == "Y"
795
            and region_index != len(tiling_params["region_params"]) - 1
796
        ):
797
            fov_regions["fovs"].append(moly_point)
1✔
798

799
        print("Finished generating FOVs for region %s" % region_info["region_name"])
1✔
800

801
    return fov_regions
1✔
802

803

804
def validate_tma_corners(top_left, top_right, bottom_left, bottom_right):
1✔
805
    """Ensures that the provided TMA corners match assumptions.
806

807
    Args:
808
        top_left (XYCoord): coordinate (in microns) of top left corner
809
        top_right (XYCoord): coordinate (in microns) of top right corner
810
        bottom_left (XYCoord): coordinate (in microns) of bottom right corner
811
        bottom_right (XYCoord): coordinate (in microns)of bottom right corner
812

813
    """
814
    # TODO: should we programmatically validate all pairwise comparisons?
815

816
    # verify positions relative to the slide
817
    if not verify_coordinate_on_slide((top_left.x, top_left.y), "micron"):
1✔
818
        raise ValueError("Top-left coordinate provided is out of bounds")
1✔
819

820
    if not verify_coordinate_on_slide((top_right.x, top_right.y), "micron"):
1✔
821
        raise ValueError("Top-right coordinate provided is out of bounds")
1✔
822

823
    if not verify_coordinate_on_slide((bottom_left.x, bottom_left.y), "micron"):
1✔
824
        raise ValueError("Bottom-left coordinate provided is out of bounds")
1✔
825

826
    if not verify_coordinate_on_slide((bottom_right.x, bottom_right.y), "micron"):
1✔
827
        raise ValueError("Bottom-right coordinate provided is out of bounds")
1✔
828

829
    # verify positions relative to each other corners
830
    if top_left.x > top_right.x:
1✔
831
        raise ValueError(
1✔
832
            "Invalid corner file: The upper left corner is to the right of the upper right corner"
833
        )
834

835
    if bottom_left.x > bottom_right.x:
1✔
836
        raise ValueError(
1✔
837
            "Invalid corner file: The bottom left corner is to the right of the bottom right corner"
838
        )
839

840
    if top_left.y < bottom_left.y:
1✔
841
        raise ValueError(
1✔
842
            "Invalid corner file: The upper left corner is below the bottom left corner"
843
        )
844

845
    if top_right.y < bottom_right.y:
1✔
846
        raise ValueError(
1✔
847
            "Invalid corner file: The upper right corner is below the bottom right corner"
848
        )
849

850

851
@dataclass
1✔
852
class XYCoord:
1✔
853
    """Class for x,y coordinates of the tma."""
1✔
854

855
    x: float
1✔
856
    y: float
1✔
857

858

859
def generate_tma_fov_list(tma_corners_path, num_fov_row, num_fov_col):
1✔
860
    """Generate the list of FOVs on the image using the TMA input file in `tma_corners_path`.
861

862
    NOTE: unlike tiled regions, the returned list of FOVs is just an intermediate step to the
863
    interactive remapping process. So the result will just be each FOV name mapped to its centroid
864
    (in microns).
865

866
    Args:
867
        tma_corners_path (dict):
868
            Path to the JSON file containing the FOVs used to define the tiled TMA region
869
        num_fov_row (int):
870
            Number of FOVs along the row dimension
871
        num_fov_col (int):
872
            Number of FOVs along the column dimension
873

874
    Returns:
875
        dict:
876
            Data containing information about each FOV (just FOV name mapped to centroid)
877
    """
878
    # file path validation
879
    if not os.path.exists(tma_corners_path):
1✔
880
        raise FileNotFoundError("TMA corners file %s does not exist" % tma_corners_path)
1✔
881

882
    # user needs to define at least 2 FOVs along the row- and col-axes
883
    if num_fov_row < 2:
1✔
884
        raise ValueError("Number of TMA-grid rows must be at least 2")
1✔
885

886
    if num_fov_col < 2:
1✔
887
        raise ValueError("Number of TMA-grid columns must be at least 2")
1✔
888

889
    # read in tma_corners_path
890
    tma_corners = json_utils.read_json_file(tma_corners_path, encoding="utf-8")
1✔
891

892
    tma_corners = json_utils.rename_missing_fovs(tma_corners)
1✔
893

894
    # a TMA can only be defined by four FOVs, one for each corner
895
    if len(tma_corners["fovs"]) != 4:
1✔
896
        raise ValueError("Your FOV region file %s needs to contain four FOVs" % tma_corners)
1✔
897

898
    # retrieve the FOVs from JSON file
899
    corners = [0] * 4
1✔
900
    for i, fov in enumerate(tma_corners["fovs"]):
1✔
901
        corners[i] = XYCoord(*itemgetter("x", "y")(fov["centerPointMicrons"]))
1✔
902

903
    top_left, top_right, bottom_left, bottom_right = corners
1✔
904

905
    validate_tma_corners(top_left, top_right, bottom_left, bottom_right)
1✔
906

907
    # create all x_y coordinates
908
    x_y_pairs = generate_x_y_fov_pairs_rhombus(
1✔
909
        top_left, top_right, bottom_left, bottom_right, num_fov_row, num_fov_col
910
    )
911

912
    # name the FOVs according to MIBI conventions
913
    fov_names = ["R%dC%d" % (y + 1, x + 1) for x in range(num_fov_col) for y in range(num_fov_row)]
1✔
914

915
    # define the fov_regions dict
916
    fov_regions = {}
1✔
917

918
    # map each name to its corresponding coordinate value
919
    for index, (xi, yi) in enumerate(x_y_pairs):
1✔
920
        # NOTE: because the FOVs are generated within the four corners, and these are validated
921
        # beforehand, this should never throw an error
922
        if not verify_coordinate_on_slide((xi, yi), "micron"):
1✔
923
            raise ValueError(
×
924
                "Coordinate (%.2f, %.2f) for FOV %s out of range, "
925
                "please double check the TMA dimensions" % (xi, yi, fov_names[index])
926
            )
927
        fov_regions[fov_names[index]] = (xi, yi)
1✔
928

929
    return fov_regions
1✔
930

931

932
def convert_stage_to_optical(coord, stage_optical_coreg_params):
1✔
933
    """Convert the coordinate in stage microns to optical pixels.
934

935
    In other words, co-register using the centroid of a FOV.
936

937
    The values are coerced to ints to allow indexing into the slide.
938
    Coordinates are returned in `(y, x)` form to account for a different coordinate axis.
939

940
    Args:
941
        coord (tuple):
942
            The coordinate in microns to convert
943
        stage_optical_coreg_params (dict):
944
            Contains the co-registration parameters to use
945

946
    Returns:
947
        tuple:
948
            The converted coordinate from stage microns to optical pixels.
949
            Values truncated to `int`.
950
    """
951
    stage_to_optical_x_multiplier = stage_optical_coreg_params["STAGE_TO_OPTICAL_X_MULTIPLIER"]
1✔
952
    stage_to_optical_x_offset = stage_optical_coreg_params["STAGE_TO_OPTICAL_X_OFFSET"]
1✔
953
    stage_to_optical_y_multiplier = stage_optical_coreg_params["STAGE_TO_OPTICAL_Y_MULTIPLIER"]
1✔
954
    stage_to_optical_y_offset = stage_optical_coreg_params["STAGE_TO_OPTICAL_Y_OFFSET"]
1✔
955

956
    # NOTE: all conversions are done using the fiducials
957
    # convert from microns to stage coordinates
958
    stage_coord_x = (
1✔
959
        coord[0] * settings.MICRON_TO_STAGE_X_MULTIPLIER - settings.MICRON_TO_STAGE_X_OFFSET
960
    )
961
    stage_coord_y = (
1✔
962
        coord[1] * settings.MICRON_TO_STAGE_Y_MULTIPLIER - settings.MICRON_TO_STAGE_Y_OFFSET
963
    )
964

965
    # convert from stage coordinates to optical pixels
966
    pixel_coord_x = (stage_coord_x + stage_to_optical_x_offset) * stage_to_optical_x_multiplier
1✔
967
    pixel_coord_y = (stage_coord_y + stage_to_optical_y_offset) * stage_to_optical_y_multiplier
1✔
968

969
    return (int(pixel_coord_y), int(pixel_coord_x))
1✔
970

971

972
def assign_closest_fovs(manual_fovs, auto_fovs):
1✔
973
    """For each FOV in `manual_fovs`, map it to its closest FOV in `auto_fovs`.
974

975
    Args:
976
        manual_fovs (dict):
977
            The list of FOVs proposed by the user
978
        auto_fovs (dict):
979
            The list of FOVs generated by `set_tiling_params` in `example_fov_grid_generate.ipynb`
980

981
    Returns:
982
        tuple:
983

984
        - A `dict` mapping each manual FOV to an auto FOV and its respective distance
985
          (in microns) from it
986
        - A `pandas.DataFrame` defining the distance (in microns) from each manual FOV
987
          to each auto FOV, row indices are manual FOVs, column names are auto FOVs
988
    """
989
    # condense the manual FOVs JSON list into just a mapping between name and coordinate
990
    manual_fovs_name_coord = {
1✔
991
        fov["name"]: tuple(list(fov["centerPointMicrons"].values())) for fov in manual_fovs["fovs"]
992
    }
993

994
    # retrieve the centroids in array format for distance calculation
995
    manual_centroids = np.array([list(centroid) for centroid in manual_fovs_name_coord.values()])
1✔
996

997
    auto_centroids = np.array([list(centroid) for centroid in auto_fovs.values()])
1✔
998

999
    # define the mapping dict from manual to auto
1000
    manual_to_auto_map = {}
1✔
1001

1002
    # compute the euclidean distances between the manual and the auto centroids
1003
    manual_auto_dist = np.linalg.norm(manual_centroids[:, np.newaxis] - auto_centroids, axis=2)
1✔
1004

1005
    # for each manual fov, get the index of the auto fov closest to it
1006
    closest_auto_point_ind = np.argmin(manual_auto_dist, axis=1)
1✔
1007

1008
    # assign the mapping in manual_to_auto_map
1009
    for manual_index, auto_index in enumerate(closest_auto_point_ind):
1✔
1010
        # get the corresponding fov names
1011
        man_name = list(manual_fovs_name_coord.keys())[manual_index]
1✔
1012
        auto_name = list(auto_fovs.keys())[auto_index]
1✔
1013

1014
        # map the manual fov name to its closest auto fov name
1015
        manual_to_auto_map[man_name] = auto_name
1✔
1016

1017
    # convert manual_auto_dist into a Pandas DataFrame, this makes it easier to index
1018
    manual_auto_dist = pd.DataFrame(
1✔
1019
        manual_auto_dist,
1020
        index=list(manual_fovs_name_coord.keys()),
1021
        columns=list(auto_fovs.keys()),
1022
    )
1023

1024
    return manual_to_auto_map, manual_auto_dist
1✔
1025

1026

1027
def generate_fov_circles(
1✔
1028
    manual_fovs_info, auto_fovs_info, manual_name, auto_name, slide_img, draw_radius=7
1029
):
1030
    """Draw the circles defining each FOV (manual and auto) on `slide_img`.
1031

1032
    Args:
1033
        manual_fovs_info (dict):
1034
            maps each manual FOV to its centroid coordinates (in optical pixels)
1035
        auto_fovs_info (dict):
1036
            maps each auto FOV to its centroid coordinates (in optical pixels)
1037
        manual_name (str):
1038
            the name of the manual FOV to highlight
1039
        auto_name (str):
1040
            the name of the automatically-generated FOV to highlight
1041
        slide_img (numpy.ndarray):
1042
            the image to overlay
1043
        draw_radius (int):
1044
            the radius (in optical pixels) of the circle to overlay for each FOV
1045

1046
    Returns:
1047
        numpy.ndarray:
1048
            `slide_img` with defining each manually-defined and automatically-generated FOV
1049
    """
1050
    # define dicts to hold the coordinates
1051

1052
    # define the fov size boundaries, needed to prevent drawing a circle out of bounds
1053
    fov_size = slide_img.shape[:2]
1✔
1054

1055
    # generate the regions for each manual and mapped auto fov
1056
    for mfi in manual_fovs_info:
1✔
1057
        # get the x- and y-coordinate of the centroid
1058
        manual_x = int(manual_fovs_info[mfi][0])
1✔
1059
        manual_y = int(manual_fovs_info[mfi][1])
1✔
1060

1061
        # define the circle coordinates for the region
1062
        mr_x, mr_y = ellipse(manual_x, manual_y, draw_radius, draw_radius, shape=fov_size)
1✔
1063

1064
        # color the selected manual fov dark red, else bright red
1065
        if mfi == manual_name:
1✔
1066
            slide_img[mr_x, mr_y, 0] = 210
1✔
1067
            slide_img[mr_x, mr_y, 1] = 37
1✔
1068
            slide_img[mr_x, mr_y, 2] = 37
1✔
1069
        else:
1070
            slide_img[mr_x, mr_y, 0] = 255
1✔
1071
            slide_img[mr_x, mr_y, 1] = 133
1✔
1072
            slide_img[mr_x, mr_y, 2] = 133
1✔
1073

1074
    # repeat but for the automatically generated points
1075
    for afi in auto_fovs_info:
1✔
1076
        # repeat the above for auto points
1077
        auto_x = int(auto_fovs_info[afi][0])
1✔
1078
        auto_y = int(auto_fovs_info[afi][1])
1✔
1079

1080
        # define the circle coordinates for the region
1081
        ar_x, ar_y = ellipse(auto_x, auto_y, draw_radius, draw_radius, shape=fov_size)
1✔
1082

1083
        # color the selected auto fov dark blue, else bright blue
1084
        if afi == auto_name:
1✔
1085
            slide_img[ar_x, ar_y, 0] = 50
1✔
1086
            slide_img[ar_x, ar_y, 1] = 115
1✔
1087
            slide_img[ar_x, ar_y, 2] = 229
1✔
1088
        else:
1089
            slide_img[ar_x, ar_y, 0] = 162
1✔
1090
            slide_img[ar_x, ar_y, 1] = 197
1✔
1091
            slide_img[ar_x, ar_y, 2] = 255
1✔
1092

1093
    return slide_img
1✔
1094

1095

1096
def update_mapping_display(
1✔
1097
    change,
1098
    w_auto,
1099
    manual_to_auto_map,
1100
    manual_coords,
1101
    auto_coords,
1102
    slide_img,
1103
    draw_radius=7,
1104
):
1105
    """Changes the highlighted manual-auto fov pair on the image based on new selected manual FOV.
1106

1107
    Helper to `update_mapping` nested callback function in `interactive_remap`
1108

1109
    Args:
1110
        change (dict):
1111
            defines the properties of the changed value of the manual FOV menu
1112
        w_auto (ipywidgets.widgets.widget_selection.Dropdown):
1113
            the dropdown menu handler for the automatically-generated FOVs
1114
        manual_to_auto_map (dict):
1115
            defines the mapping of manual to auto FOV names
1116
        manual_coords (dict):
1117
            maps each manually-defined FOV to its coordinate (in optical pixels)
1118
        auto_coords (dict):
1119
            maps each automatically-generated FOV to its coordinate (in optical pixels)
1120
        slide_img (numpy.ndarray):
1121
            the image to overlay
1122
        draw_radius (int):
1123
            the radius (in optical pixels) to draw each circle on the slide
1124

1125
    Returns:
1126
        numpy.ndarray:
1127
            `slide_img` with the updated circles after manual fov changed
1128
    """
1129
    # define the fov size boundaries, needed to prevent drawing a circle out of bounds
1130
    fov_size = slide_img.shape[:2]
1✔
1131

1132
    # retrieve the old manual centroid
1133
    old_manual_x, old_manual_y = manual_coords[change["old"]]
1✔
1134

1135
    # redraw the old manual centroid on the slide_img
1136
    old_mr_x, old_mr_y = ellipse(
1✔
1137
        old_manual_x, old_manual_y, draw_radius, draw_radius, shape=fov_size
1138
    )
1139

1140
    slide_img[old_mr_x, old_mr_y, 0] = 255
1✔
1141
    slide_img[old_mr_x, old_mr_y, 1] = 133
1✔
1142
    slide_img[old_mr_x, old_mr_y, 2] = 133
1✔
1143

1144
    # retrieve the old auto centroid
1145
    old_auto_x, old_auto_y = auto_coords[w_auto.value]
1✔
1146

1147
    # redraw the old auto centroid on the slide_img
1148
    old_ar_x, old_ar_y = ellipse(old_auto_x, old_auto_y, draw_radius, draw_radius, shape=fov_size)
1✔
1149

1150
    slide_img[old_ar_x, old_ar_y, 0] = 162
1✔
1151
    slide_img[old_ar_x, old_ar_y, 1] = 197
1✔
1152
    slide_img[old_ar_x, old_ar_y, 2] = 255
1✔
1153

1154
    # retrieve the new manual centroid
1155
    new_manual_x, new_manual_y = manual_coords[change["new"]]
1✔
1156

1157
    # redraw the new manual centroid on the slide_img
1158
    new_mr_x, new_mr_y = ellipse(
1✔
1159
        new_manual_x, new_manual_y, draw_radius, draw_radius, shape=fov_size
1160
    )
1161

1162
    slide_img[new_mr_x, new_mr_y, 0] = 210
1✔
1163
    slide_img[new_mr_x, new_mr_y, 1] = 37
1✔
1164
    slide_img[new_mr_x, new_mr_y, 2] = 37
1✔
1165

1166
    # retrieve the new auto centroid
1167
    new_auto_x, new_auto_y = auto_coords[manual_to_auto_map[change["new"]]]
1✔
1168

1169
    # redraw the new auto centroid on the slide_img
1170
    new_ar_x, new_ar_y = ellipse(new_auto_x, new_auto_y, draw_radius, draw_radius, shape=fov_size)
1✔
1171

1172
    slide_img[new_ar_x, new_ar_y, 0] = 50
1✔
1173
    slide_img[new_ar_x, new_ar_y, 1] = 115
1✔
1174
    slide_img[new_ar_x, new_ar_y, 2] = 229
1✔
1175

1176
    # set the mapped auto value according to the new manual value
1177
    w_auto.value = manual_to_auto_map[change["new"]]
1✔
1178

1179
    return slide_img
1✔
1180

1181

1182
def remap_manual_to_auto_display(
1✔
1183
    change,
1184
    w_man,
1185
    manual_to_auto_map,
1186
    manual_auto_dist,
1187
    auto_coords,
1188
    slide_img,
1189
    draw_radius=7,
1190
    check_dist=2000,
1191
    check_duplicates=True,
1192
    check_mismatches=True,
1193
):
1194
    """Changes the highlighted automatically-generated FOV to new selected auto FOV
1195
    and updates the mapping in `manual_to_auto_map`.
1196

1197
    Helper to `remap_values` nested callback function in `interactive_remap`
1198

1199
    Args:
1200
        change (dict):
1201
            defines the properties of the changed value of the automatically-generated FOV menu
1202
        w_man (ipywidgets.widgets.widget_selection.Dropdown):
1203
            the dropdown menu handler for the manual FOVs
1204
        manual_to_auto_map (dict):
1205
            defines the mapping of manual to auto FOV names
1206
        manual_auto_dist (pandas.DataFrame):
1207
            defines the distance (in microns) between each manual FOV from each auto FOV
1208
        auto_coords (dict):
1209
            maps each automatically-generated FOV to its coordinate (in optical pixels)
1210
        slide_img (numpy.ndarray):
1211
            the image to overlay
1212
        draw_radius (int):
1213
            the radius (in optical pixels) to draw each circle on the slide
1214
        check_dist (float):
1215
            if the distance (in microns) between a manual-auto FOV pair exceeds this value, it will
1216
            be reported for a potential error, if `None` does not validate distance
1217
        check_duplicates (bool):
1218
            if `True`, validate whether an auto FOV has 2 manual FOVs mapping to it
1219
        check_mismatches (bool):
1220
            if `True`, validate whether the the manual auto FOV pairs have matching names
1221

1222
    Returns:
1223
        tuple:
1224
            contains the following elements
1225

1226
            - `numpy.ndarray`:`slide_img` with the updated circles after auto fov changed
1227
              remapping the fovs
1228
            - `str`: the new error message to display after a remapping
1229
    """
1230
    # define the fov size boundaries, needed to prevent drawing a circle out of bounds
1231
    fov_size = slide_img.shape[:2]
1✔
1232

1233
    # retrieve the coordinates for the old auto centroid w_prop mapped to
1234
    old_auto_x, old_auto_y = auto_coords[change["old"]]
1✔
1235

1236
    # redraw the old auto centroid on the slide_img
1237
    old_ar_x, old_ar_y = ellipse(old_auto_x, old_auto_y, draw_radius, draw_radius, shape=fov_size)
1✔
1238

1239
    slide_img[old_ar_x, old_ar_y, 0] = 162
1✔
1240
    slide_img[old_ar_x, old_ar_y, 1] = 197
1✔
1241
    slide_img[old_ar_x, old_ar_y, 2] = 255
1✔
1242

1243
    # retrieve the coordinates for the new auto centroid w_prop maps to
1244
    new_auto_x, new_auto_y = auto_coords[change["new"]]
1✔
1245

1246
    # redraw the new auto centroid on the slide_img
1247
    new_ar_x, new_ar_y = ellipse(new_auto_x, new_auto_y, draw_radius, draw_radius, shape=fov_size)
1✔
1248

1249
    slide_img[new_ar_x, new_ar_y, 0] = 50
1✔
1250
    slide_img[new_ar_x, new_ar_y, 1] = 115
1✔
1251
    slide_img[new_ar_x, new_ar_y, 2] = 229
1✔
1252

1253
    # remap the manual fov to the changed value
1254
    manual_to_auto_map[w_man.value] = change["new"]
1✔
1255

1256
    # define the potential sources of error in the new mapping
1257
    manual_auto_warning = generate_validation_annot(
1✔
1258
        manual_to_auto_map,
1259
        manual_auto_dist,
1260
        check_dist,
1261
        check_duplicates,
1262
        check_mismatches,
1263
    )
1264

1265
    return slide_img, manual_auto_warning
1✔
1266

1267

1268
def save_json(json_data, save_ann, json_path):
1✔
1269
    """Saves `json_data` to `json_path` and notifies user through `save_ann`.
1270

1271
    Helper to `save_mapping` nested callback function in tiled region and tma visualizations
1272

1273
    Args:
1274
        json_data (dict):
1275
            the JSON data to save
1276
        save_ann (dict):
1277
            contains the annotation object defining the save notification
1278
        json_path (str):
1279
            the path to save the JSON data to
1280
    """
1281
    # save the mapping
1282
    json_utils.write_json_file(json_path=json_path, json_object=json_data, encoding="utf-8")
1✔
1283

1284
    # remove the save annotation if it already exists
1285
    # clears up some space if the user decides to save several times
1286
    if save_ann["annotation"]:
1✔
1287
        save_ann["annotation"].remove()
1✔
1288

1289
    # get the current datetime, need to display when the annotation was saved
1290
    timestamp = datetime.now().strftime("%d-%m-%Y %H:%M:%S")
1✔
1291

1292
    # display save annotation above the plot
1293
    save_msg = plt.annotate(
1✔
1294
        "Mapping saved at %s!" % timestamp,
1295
        (0, 20),
1296
        color="white",
1297
        fontweight="bold",
1298
        annotation_clip=False,
1299
    )
1300

1301
    # assign annotation to save_ann
1302
    save_ann["annotation"] = save_msg
1✔
1303

1304

1305
# TODO: potential type hinting candidate?
1306
def find_manual_auto_invalid_dist(manual_to_auto_map, manual_auto_dist, dist_threshold=2000):
1✔
1307
    """Finds the manual FOVs that map to auto FOVs greater than `dist_threshold` away (in microns).
1308

1309
    Args:
1310
        manual_to_auto_map (dict):
1311
            defines the mapping of manual to auto FOV names
1312
        manual_auto_dist (pandas.DataFrame):
1313
            defines the distance (in microns) between each manual FOV from each auto FOV
1314
        dist_threshold (float):
1315
            if the distance (in microns) between a manual-auto FOV pair exceeds this value, it will
1316
            be reported for a potential error
1317

1318
    Returns:
1319
        list:
1320
            contains tuples with elements:
1321

1322
            - `str`: the manual FOV name
1323
            - `str`: the auto FOV name
1324
            - `float`: the distance (in microns) between the manual and auto FOV
1325

1326
            applies only for manual-auto pairs with distance greater than `dist_threshold`
1327
            (in microns), sorted by decreasing manual-auto FOV distance
1328
    """
1329
    # define the fov pairs at a distance greater than dist_thresh
1330
    manual_auto_invalid_dist_pairs = [
1✔
1331
        (mf, af, manual_auto_dist.loc[mf, af])
1332
        for (mf, af) in manual_to_auto_map.items()
1333
        if manual_auto_dist.loc[mf, af] > dist_threshold
1334
    ]
1335

1336
    # sort these fov pairs by distance descending
1337
    manual_auto_invalid_dist_pairs = sorted(
1✔
1338
        manual_auto_invalid_dist_pairs, key=lambda val: val[2], reverse=True
1339
    )
1340

1341
    return manual_auto_invalid_dist_pairs
1✔
1342

1343

1344
def find_duplicate_auto_mappings(manual_to_auto_map):
1✔
1345
    """Finds each auto FOV with more than one manual FOV mapping to it.
1346

1347
    Args:
1348
        manual_to_auto_map (dict):
1349
            defines the mapping of manual to auto FOV names
1350

1351
    Returns:
1352
        list:
1353
            contains tuples with elements:
1354

1355
            - `str`: the name of the auto FOV
1356
            - `tuple`: the set of manual FOVs that map to the auto FOV
1357

1358
            only for auto FOVs with more than one manual FOV mapping to it
1359
    """
1360
    # "reverse" manual_to_auto_map: for each auto FOV find the list of manual FOVs that map to it
1361
    auto_fov_mappings = {}
1✔
1362

1363
    # good ol' incremental dict building!
1364
    for mf in manual_to_auto_map:
1✔
1365
        closest_auto_fov = manual_to_auto_map[mf]
1✔
1366

1367
        if closest_auto_fov not in auto_fov_mappings:
1✔
1368
            auto_fov_mappings[closest_auto_fov] = []
1✔
1369

1370
        auto_fov_mappings[closest_auto_fov].append(mf)
1✔
1371

1372
    # only keep the auto FOVs with more than one manual FOV mapping to it
1373
    duplicate_auto_fovs = [
1✔
1374
        (af, tuple(mf_list)) for (af, mf_list) in auto_fov_mappings.items() if len(mf_list) > 1
1375
    ]
1376

1377
    # sort auto FOVs alphabetically
1378
    duplicate_auto_fovs = sorted(duplicate_auto_fovs, key=lambda val: val[0])
1✔
1379

1380
    return duplicate_auto_fovs
1✔
1381

1382

1383
def find_manual_auto_name_mismatches(manual_to_auto_map):
1✔
1384
    """Finds the manual FOVs with names that do not match their corresponding auto FOV.
1385

1386
    Args:
1387
        manual_to_auto_map (dict):
1388
            defines the mapping of manual to auto FOV names
1389

1390
    Returns:
1391
        list:
1392
            contains tuples with elements:
1393

1394
            - `str`: the manual FOV
1395
            - `str`: the corresponding auto FOV
1396
    """
1397
    # find the manual FOVs that don't match their corresponding closest_auto_fov name
1398
    # NOTE: this method maintains the original manual FOV ordering which is already sorted
1399
    manual_auto_mismatches = [(k, v) for (k, v) in manual_to_auto_map.items() if k != v]
1✔
1400

1401
    return manual_auto_mismatches
1✔
1402

1403

1404
# TODO: potential type hinting candidate?
1405
def generate_validation_annot(
1✔
1406
    manual_to_auto_map,
1407
    manual_auto_dist,
1408
    check_dist=2000,
1409
    check_duplicates=True,
1410
    check_mismatches=True,
1411
):
1412
    """Finds problematic manual-auto FOV pairs and generates a warning message to display.
1413

1414
    The following potential sources of error can be requested by the user:
1415

1416
    - Manual to auto FOV pairs that are of a distance greater than `check_dist` away (in microns)
1417
    - Auto FOV names that have more than one manual FOV name mapping to it
1418
    - Manual to auto FOV pairs that don't have the same name
1419

1420
    Args:
1421
        manual_to_auto_map (dict):
1422
            defines the mapping of manual to auto FOV names
1423
        manual_auto_dist (pandas.DataFrame):
1424
            defines the distance (in microns) between each manual FOV from each auto FOV
1425
        check_dist (float):
1426
            if the distance (in microns) between a manual-auto FOV pair exceeds this value, it will
1427
            be reported for a potential error, if `None` does not validate distance
1428
        check_duplicates (bool):
1429
            if `True`, report each auto FOV with 2 or more manual FOVs mapping to it
1430
        check_mismatches (bool):
1431
            if `True`, report manual FOVs that map to an auto FOV with a different name
1432

1433
    Returns:
1434
        str:
1435
            describes the validation failures
1436
    """
1437
    # define the potential sources of error desired by user in the mapping
1438
    invalid_dist = (
1✔
1439
        find_manual_auto_invalid_dist(manual_to_auto_map, manual_auto_dist, check_dist)
1440
        if check_dist is not None
1441
        else []
1442
    )
1443
    duplicate_auto = find_duplicate_auto_mappings(manual_to_auto_map) if check_duplicates else []
1✔
1444
    name_mismatch = find_manual_auto_name_mismatches(manual_to_auto_map) if check_mismatches else []
1✔
1445

1446
    # generate the annotation
1447
    warning_annot = ""
1✔
1448

1449
    # add the manual-auto FOV pairs with distances greater than check_dist
1450
    if len(invalid_dist) > 0:
1✔
1451
        warning_annot += (
1✔
1452
            "The following mappings are placed more than %d microns apart:\n\n" % check_dist
1453
        )
1454

1455
        warning_annot += "\n".join(
1✔
1456
            [
1457
                "User-defined FOV %s to TMA-grid FOV %s (distance: %.2f)" % (mf, af, dist)
1458
                for (mf, af, dist) in invalid_dist
1459
            ]
1460
        )
1461

1462
        warning_annot += "\n\n"
1✔
1463

1464
    # add the auto FOVs that have more than one manual FOV mapping to it
1465
    if len(duplicate_auto) > 0:
1✔
1466
        warning_annot += (
1✔
1467
            "The following TMA-grid FOVs have more than one user-defined FOV mapping to it:\n\n"
1468
        )
1469

1470
        warning_annot += "\n".join(
1✔
1471
            [
1472
                "TMA-grid FOV %s: mapped with user-defined FOVs %s" % (af, ", ".join(mf_tuple))
1473
                for (af, mf_tuple) in duplicate_auto
1474
            ]
1475
        )
1476

1477
        warning_annot += "\n\n"
1✔
1478

1479
    # add the manual-auto FOV pairs with mismatched names
1480
    if len(name_mismatch) > 0:
1✔
1481
        warning_annot += "The following mappings have mismatched names:\n\n"
1✔
1482

1483
        warning_annot += "\n".join(
1✔
1484
            [
1485
                "User-defined FOV %s: mapped with TMA-grid FOV %s" % (mf, af)
1486
                for (mf, af) in name_mismatch
1487
            ]
1488
        )
1489

1490
        warning_annot += "\n\n"
1✔
1491

1492
    return warning_annot
1✔
1493

1494

1495
class FOVRectangle:
1✔
1496
    """Class containing FOV rectangle information."""
1✔
1497

1498
    # ensure the rectangles don't try to operate on each other
1499
    lock = None
1✔
1500

1501
    def __init__(self, coords, width, height, color, id_val, ax):
1✔
1502
        """Initialize FOVRectangle."""
1503
        rect = Rectangle(coords, width, height, color=color, fill=False, linewidth=1)
1✔
1504
        ax.add_patch(rect)
1✔
1505
        self.rect = rect
1✔
1506
        self.id_val = id_val
1✔
1507
        self.press = None
1✔
1508

1509
    def connect(self):
1✔
1510
        """Connect to all the events we need."""
1511
        self.cidpress = self.rect.figure.canvas.mpl_connect("button_press_event", self.on_press)
1✔
1512
        self.cidrelease = self.rect.figure.canvas.mpl_connect(
1✔
1513
            "button_release_event", self.on_release
1514
        )
1515

1516
    def on_press(self, event):
1✔
1517
        """Check whether mouse is over us; if so, store some data."""
1518
        # ensure we only operate on the correct rectangle in unlocked state
1519
        if event.inaxes != self.rect.axes or FOVRectangle.lock is not None:
×
1520
            return
×
1521
        contains, attrd = self.rect.contains(event)
×
1522
        if not contains:
×
1523
            return
×
1524

1525
        # store information about the mouse press
1526
        self.press = self.rect.xy, (event.xdata, event.ydata), self.id_val
×
1527

1528
        # lock other rectangles out
1529
        FOVRectangle.lock = self
×
1530
        # self.rect.figure.canvas.draw()
1531

1532
    def on_release(self, event):
1✔
1533
        """Set the new border and clear button press information."""
1534
        # ensure we don't try to release a rectangle in locked state
1535
        if FOVRectangle.lock is not self:
×
1536
            return
×
1537

1538
        # toggle the border width between 1 and 5 on click
1539
        if self.id_val == self.press[2]:
×
1540
            if self.rect.get_linewidth() == 1:
×
1541
                self.rect.set_linewidth(5)
×
1542
            else:
1543
                self.rect.set_linewidth(1)
×
1544

1545
        # clear the press and lock info
1546
        self.press = None
×
1547
        FOVRectangle.lock = None
×
1548

1549
        # re-draw the rectangle
1550
        self.rect.figure.canvas.draw()
×
1551

1552
    def disconnect(self):
1✔
1553
        """Disconnect all callbacks."""
1554
        self.rect.figure.canvas.mpl_disconnect(self.cidpress)
×
1555
        self.rect.figure.canvas.mpl_disconnect(self.cidrelease)
×
1556

1557

1558
def generate_fov_rectangle(fov_info, region_colors, stage_optical_coreg_params, ax):
1✔
1559
    """Draws an interactive rectangle for the given FOV.
1560

1561
    Args:
1562
        fov_info (dict):
1563
            Defines the name, centroid, and size of the FOV in the tiled region
1564
        region_colors (dict):
1565
            Maps each FOV to its respective color
1566
        stage_optical_coreg_params (dict):
1567
            Contains the co-registration parameters to use
1568
        ax (matplotlib.axes.Axes):
1569
            The axis to draw the rectangle on
1570

1571
    Returns:
1572
        FOVRectangle:
1573
            The interactive rectangle object associated with the FOV to draw
1574
    """
1575
    # extract the region name, this method accounts for '_' in the region name itself
1576
    fov_region = "_".join(fov_info["name"].split("_")[:-1])
1✔
1577

1578
    # use the region name to extract the color
1579
    fov_color = region_colors[fov_region]
1✔
1580

1581
    # get the centroid coordinates
1582
    fov_centroid = tuple(fov_info["centerPointMicrons"].values())
1✔
1583

1584
    # define the top-left corner, subtract fovSizeMicrons from x and add it to y
1585
    fov_size = fov_info["fovSizeMicrons"]
1✔
1586
    fov_top_left_microns = (
1✔
1587
        fov_centroid[0] - fov_size / 2,
1588
        fov_centroid[1] + fov_size / 2,
1589
    )
1590

1591
    # co-register the top-left fov corner
1592
    fov_top_left_pixels = convert_stage_to_optical(fov_top_left_microns, stage_optical_coreg_params)
1✔
1593

1594
    # we'll also need to find the length and width of the rectangle
1595
    # NOTE: we do so by co-registering the bottom-left and top-right points
1596
    # then finding the distance from those to the top-left
1597
    fov_bottom_left_microns = (
1✔
1598
        fov_centroid[0] - fov_size / 2,
1599
        fov_centroid[1] - fov_size / 2,
1600
    )
1601
    fov_bottom_left_pixels = convert_stage_to_optical(
1✔
1602
        fov_bottom_left_microns, stage_optical_coreg_params
1603
    )
1604

1605
    fov_top_right_microns = (
1✔
1606
        fov_centroid[0] + fov_size / 2,
1607
        fov_centroid[1] + fov_size / 2,
1608
    )
1609
    fov_top_right_pixels = convert_stage_to_optical(
1✔
1610
        fov_top_right_microns, stage_optical_coreg_params
1611
    )
1612

1613
    fov_height = fov_bottom_left_pixels[0] - fov_top_left_pixels[0]
1✔
1614
    fov_width = fov_top_right_pixels[1] - fov_top_left_pixels[1]
1✔
1615

1616
    # draw the rectangle defining this fov
1617
    # NOTE: the x-y axis for rectangles introduces yet another coordinate axis system,
1618
    # so we'll need to reverse the order fov_top_left_pixels is passed in
1619
    fov_top_left_pixels_rect = tuple(reversed(fov_top_left_pixels))
1✔
1620
    dr = FOVRectangle(
1✔
1621
        fov_top_left_pixels_rect, fov_width, fov_height, fov_color, fov_info["name"], ax
1622
    )
1623

1624
    return dr
1✔
1625

1626

1627
def delete_tiled_region_fovs(rectangles, tiled_region_fovs):
1✔
1628
    """Delete all the FOVs from tiled_region_fovs with lindwidth 5 (indicating its been selected).
1629

1630
    Helper function to `delete_fovs` in `tiled_region_interactive_remap`
1631

1632

1633
    Args:
1634
        rectangles (dict):
1635
            Maps each FOV to its corresponding rectangle instance
1636
        tiled_region_fovs (dict):
1637
            The list of FOVs to overlay for each tiled region
1638
    """
1639
    # define a list of FOVs to delete
1640
    # NOTE: only FOVs with a linewidth of 5, indicates user has selected it
1641
    fovs_delete = [fov for fov in rectangles if rectangles[fov].rect.get_linewidth() == 5]
1✔
1642

1643
    # overwrite the list of FOVs in tiled_region_fovs to only contain FOVs not in fov_delete
1644
    tiled_region_fovs["fovs"] = [
1✔
1645
        fov for fov in tiled_region_fovs["fovs"] if fov["name"] not in fovs_delete
1646
    ]
1647

1648
    # now delete the corresponding rectangle for each FOV in fovs_delete
1649
    for fov in fovs_delete:
1✔
1650
        rectangles[fov].rect.remove()
1✔
1651
        del rectangles[fov]
1✔
1652

1653

1654
def tiled_region_interactive_remap(
1✔
1655
    tiled_region_fovs,
1656
    tiling_params,
1657
    slide_img,
1658
    tiled_region_path,
1659
    coreg_path=settings.COREG_SAVE_PATH,
1660
    figsize=(7, 7),
1661
):
1662
    """Creates the tiled region interactive interface for manual to auto FOVs.
1663

1664
    Args:
1665
        tiled_region_fovs (dict):
1666
            The list of FOVs to overlay for each tiled region
1667
        tiling_params (dict):
1668
            The tiling parameters generated for each tiled region
1669
        slide_img (numpy.ndarray):
1670
            The image to overlay
1671
        tiled_region_path (str):
1672
            The path to the file to save the tiled regions to
1673
        coreg_path (str):
1674
            the path to the co-registration params
1675
        figsize (tuple):
1676
            The size of the interactive figure to display
1677
    """
1678
    # error check: ensure mapping path exists
1679
    if not os.path.exists(os.path.split(tiled_region_path)[0]):
1✔
1680
        raise FileNotFoundError(
1✔
1681
            "Path %s to tiled_region_path does not exist, please rename to a valid location"
1682
            % os.path.split(tiled_region_path)[0]
1683
        )
1684

1685
    # if there isn't a coreg_path defined, the user needs to run update_coregistration_params first
1686
    if not os.path.exists(coreg_path):
1✔
1687
        raise FileNotFoundError(
1✔
1688
            "You haven't co-registered your slide yet. Please run 1_set_up_toffy.ipynb first."
1689
        )
1690

1691
    # load the co-registration parameters in
1692
    # NOTE: the last set of params in the coreg_params list is the most up-to-date
1693
    stage_optical_coreg_params = json_utils.read_json_file(coreg_path)["coreg_params"][-1]
1✔
1694

1695
    # map each region to a unique color
1696
    # TODO: default to tab20 discrete cmap, make customizable?
1697
    cm = plt.get_cmap("tab20")
1✔
1698
    region_colors = {}
1✔
1699
    for index, region in enumerate(tiling_params["region_params"]):
1✔
1700
        region_colors[region["region_name"]] = cm(index)
1✔
1701

1702
    # define an output context to display
1703
    out = widgets.Output()
1✔
1704

1705
    # define a dict to store the rectangles
1706
    rectangles = {}
1✔
1707

1708
    # define status of the save annotation, initially None, updates when user clicks w_save
1709
    # NOTE: ipywidget callback functions can only access dicts defined in scope
1710
    save_ann = {"annotation": None}
1✔
1711

1712
    def delete_fovs(b):
1✔
1713
        """Deletes the FOVs from the `rectangles` dict, `tiled_region_fovs`, and the image.
1714

1715
        Args:
1716
            b (ipywidgets.widgets.widget_button.Button):
1717
                the button handler for `w_delete`, passed as a standard for `on_click` callback
1718
        """
1719
        with out:
×
1720
            delete_tiled_region_fovs(rectangles, tiled_region_fovs)
×
1721
            ax.figure.canvas.draw()
×
1722

1723
    def save_mapping(b):
1✔
1724
        """Saves the mapping defined in `tiled_region_fovs`.
1725

1726
        Args:
1727
            b (ipywidgets.widgets.widget_button.Button):
1728
                the button handler for `w_save`, passed as a standard for `on_click` callback
1729
        """
1730
        # needs to be in the output widget context to display status
1731
        with out:
×
1732
            # call the helper function to save tiled_region_fovs and notify user
1733
            save_json(tiled_region_fovs, save_ann, tiled_region_path)
×
1734
            ax.figure.canvas.draw()
×
1735

1736
    # define the delete button
1737
    w_delete = widgets.Button(
1✔
1738
        description="Delete selected FOVs",
1739
        layout=widgets.Layout(width="auto"),
1740
        style={"description_width": "initial"},
1741
    )
1742

1743
    # define the save button
1744
    w_save = widgets.Button(
1✔
1745
        description="Save mapping",
1746
        layout=widgets.Layout(width="auto"),
1747
        style={"description_width": "initial"},
1748
    )
1749

1750
    # define a box to hold w_man and w_auto
1751
    w_box = widgets.HBox(
1✔
1752
        [w_delete, w_save],
1753
        layout=widgets.Layout(display="flex", flex_flow="row", align_items="stretch", width="75%"),
1754
    )
1755

1756
    # display the box of buttons
1757
    display(w_box)
1✔
1758

1759
    # ensure the selected FOVs get deleted when w_delete clicked
1760
    w_delete.on_click(delete_fovs)
1✔
1761

1762
    # ensure the new mapping gets saved when w_save clicked
1763
    w_save.on_click(save_mapping)
1✔
1764

1765
    # display the figure to plot on
1766
    fig = plt.figure(figsize=figsize)
1✔
1767
    ax = fig.add_subplot(111)
1✔
1768

1769
    # add a legend to indicate which region matches which color
1770
    handles = [Patch(facecolor=region_colors[rc]) for rc in region_colors]
1✔
1771
    color_names = [rc for rc in region_colors]
1✔
1772
    _ = ax.legend(
1✔
1773
        handles,
1774
        color_names,
1775
        title="ROI",
1776
        bbox_transform=plt.gcf().transFigure,
1777
        loc="upper right",
1778
    )
1779

1780
    with out:
1✔
1781
        # draw the image
1782
        _ = ax.imshow(slide_img)
1✔
1783

1784
        # overwrite the default title
1785
        _ = plt.title("Overlay of tiled region FOVs")
1✔
1786

1787
        # remove massive padding
1788
        _ = plt.tight_layout()
1✔
1789

1790
        # define a rectangle for each region
1791
        for index, fov_info in enumerate(tiled_region_fovs["fovs"]):
1✔
1792
            # skip Moly points
1793
            # TODO: will Moly points always be named MoQC?
1794
            if fov_info["name"] == "MoQC":
1✔
1795
                continue
1✔
1796

1797
            # draw the rectangle associated with the FOV
1798
            dr = generate_fov_rectangle(fov_info, region_colors, stage_optical_coreg_params, ax)
1✔
1799

1800
            # connect the rectangle to the event
1801
            dr.connect()
1✔
1802

1803
            # add the rectangle to the dict
1804
            rectangles[fov_info["name"]] = dr
1✔
1805

1806
    # display the output
1807
    display(out)
1✔
1808

1809
    return rectangles
1✔
1810

1811

1812
# TODO: potential type hinting candidate?
1813
def tma_interactive_remap(
1✔
1814
    manual_fovs,
1815
    auto_fovs,
1816
    slide_img,
1817
    mapping_path,
1818
    coreg_path=settings.COREG_SAVE_PATH,
1819
    draw_radius=7,
1820
    figsize=(7, 7),
1821
    check_dist=2000,
1822
    check_duplicates=True,
1823
    check_mismatches=True,
1824
):
1825
    """Creates the TMA remapping interactive interface for manual to auto FOVs.
1826

1827
    Args:
1828
        manual_fovs (dict):
1829
            The list of FOVs proposed by the user
1830
        auto_fovs (dict):
1831
            The list of FOVs created by `generate_tma_fov_list` run in `autolabel_tma_cores.ipynb`
1832
        slide_img (numpy.ndarray):
1833
            the image to overlay
1834
        mapping_path (str):
1835
            the path to the file to save the mapping to
1836
        coreg_path (str):
1837
            the path to the co-registration params
1838
        draw_radius (int):
1839
            the radius (in optical pixels) to draw each circle on the slide
1840
        figsize (tuple):
1841
            the size of the interactive figure to display
1842
        check_dist (float):
1843
            if the distance (in microns) between a manual-auto FOV pair exceeds this value, it will
1844
            be reported for a potential error, if `None` distance will not be validated
1845
        check_duplicates (bool):
1846
            if `True`, validate whether an auto FOV has 2 manual FOVs mapping to it
1847
        check_mismatches (bool):
1848
            if `True`, validate whether the the manual auto FOV pairs have matching names
1849
    """
1850
    # error check: ensure mapping path exists
1851
    if not os.path.exists(os.path.split(mapping_path)[0]):
1✔
1852
        raise FileNotFoundError(
1✔
1853
            "Path %s to mapping_path does not exist, please rename to a valid location"
1854
            % os.path.split(mapping_path)[0]
1855
        )
1856

1857
    # verify check_dist is positive if set as a numeric value
1858
    dist_is_num = isinstance(check_dist, int) or isinstance(check_dist, float)
1✔
1859
    if check_dist is not None and (not dist_is_num or check_dist <= 0):
1✔
1860
        raise ValueError(
1✔
1861
            "If validating distance, check_dist must be a positive floating point value"
1862
        )
1863

1864
    # verify check_duplicates is a bool
1865
    if not isinstance(check_duplicates, bool):
1✔
1866
        raise ValueError("check_duplicates needs to be set to True or False")
1✔
1867

1868
    # verify check_mismatches is a bool
1869
    if not isinstance(check_mismatches, bool):
1✔
1870
        raise ValueError("check_mismatches needs to be set to True or False")
1✔
1871

1872
    # if there isn't a coreg_path defined, the user needs to run update_coregistration_params first
1873
    if not os.path.exists(coreg_path):
1✔
1874
        raise FileNotFoundError(
1✔
1875
            "You haven't co-registered your slide yet. Please run 1_set_up_toffy.ipynb first."
1876
        )
1877

1878
    # load the co-registration parameters in
1879
    # NOTE: the last set of params in the coreg_params list is the most up-to-date
1880
    stage_optical_coreg_params = json_utils.read_json_file(coreg_path)["coreg_params"][-1]
1✔
1881

1882
    # define the initial mapping and a distance lookup table between manual and auto FOVs
1883
    manual_to_auto_map, manual_auto_dist = assign_closest_fovs(manual_fovs, auto_fovs)
1✔
1884

1885
    # condense manual_fovs to include just the name mapped to its coordinate
1886
    # NOTE: convert to optical pixels for visualization
1887
    manual_fovs_info = {
1✔
1888
        fov["name"]: convert_stage_to_optical(
1889
            tuple(fov["centerPointMicrons"].values()), stage_optical_coreg_params
1890
        )
1891
        for fov in manual_fovs["fovs"]
1892
    }
1893

1894
    # sort manual FOVs by row then column, first assume the user names FOVs in R{m}c{n} format
1895
    try:
1✔
1896
        manual_fovs_sorted = sorted(
1✔
1897
            list(manual_fovs_info.keys()),
1898
            key=lambda mf: (
1899
                int(re.findall(r"\d+", mf)[0]),
1900
                int(re.findall(r"\d+", mf)[1]),
1901
            ),
1902
        )
1903
    # otherwise, just sort manual FOVs alphabetically, nothing else we can do
1904
    # NOTE: this will not catch cases where the user has something like fov2_data0
1905
    except IndexError:
×
1906
        warnings.warn(
×
1907
            "Manual FOVs not consistently named in R{m}C{n} format, sorting alphabetically"
1908
        )
1909
        manual_fovs_sorted = sorted(list(manual_fovs_info.keys()))
×
1910

1911
    # get the first FOV to display
1912
    first_manual = manual_fovs_sorted[0]
1✔
1913

1914
    # define the drop down menu for the manual fovs
1915
    w_man = widgets.Dropdown(
1✔
1916
        options=manual_fovs_sorted,
1917
        value=first_manual,
1918
        description="Manually-defined FOV",
1919
        layout=widgets.Layout(width="auto"),
1920
        style={"description_width": "initial"},
1921
    )
1922

1923
    # NOTE: convert to optical pixels for visualization
1924
    auto_fovs_info = {
1✔
1925
        fov: convert_stage_to_optical(auto_fovs[fov], stage_optical_coreg_params)
1926
        for fov in auto_fovs
1927
    }
1928

1929
    # sort FOVs alphabetically
1930
    auto_fovs_sorted = sorted(
1✔
1931
        list(auto_fovs.keys()),
1932
        key=lambda af: (int(re.findall(r"\d+", af)[0]), int(re.findall(r"\d+", af)[1])),
1933
    )
1934

1935
    # define the drop down menu for the auto fovs
1936
    # the default value should be set to the auto fov the initial manual fov maps to
1937
    w_auto = widgets.Dropdown(
1✔
1938
        options=auto_fovs_sorted,
1939
        value=manual_to_auto_map[first_manual],
1940
        description="Automatically-generated FOV",
1941
        layout=widgets.Layout(width="auto"),
1942
        style={"description_width": "initial"},
1943
    )
1944

1945
    # define the save button
1946
    w_save = widgets.Button(
1✔
1947
        description="Save mapping",
1948
        layout=widgets.Layout(width="auto"),
1949
        style={"description_width": "initial"},
1950
    )
1951

1952
    # define the textbox to display the error message
1953
    w_err = widgets.Textarea(
1✔
1954
        description="FOV pair validation checks:",
1955
        layout=widgets.Layout(height="auto", width="50%"),
1956
        style={"description_width": "initial"},
1957
    )
1958

1959
    def increase_textarea_size(args):
1✔
1960
        """Ensures size of `w_err` adjusts based on the amount of validation text needed.
1961

1962
        Args:
1963
            args (dict):
1964
                the handler for `w_err`,
1965
                only passed as a standard for `ipywidgets.Textarea` observe
1966
        """
1967
        w_err.rows = w_err.value.count("\n") + 1
×
1968

1969
    # ensure the entire error message is displayed
1970
    w_err.observe(increase_textarea_size, "value")
1✔
1971

1972
    # define a box to hold w_man and w_auto
1973
    w_box = widgets.HBox(
1✔
1974
        [w_man, w_auto, w_save],
1975
        layout=widgets.Layout(display="flex", flex_flow="row", align_items="stretch", width="75%"),
1976
    )
1977

1978
    # display the box with w_man and w_auto dropdown menus
1979
    display(w_box)
1✔
1980

1981
    # display the w_err text box with validation errors
1982
    display(w_err)
1✔
1983

1984
    # define an output context to display
1985
    out = widgets.Output()
1✔
1986

1987
    # display the figure to plot on
1988
    fig, ax = plt.subplots(figsize=figsize)
1✔
1989

1990
    # generate the circles and annotations for each circle to display on the image
1991
    slide_img = generate_fov_circles(
1✔
1992
        manual_fovs_info,
1993
        auto_fovs_info,
1994
        w_man.value,
1995
        w_auto.value,
1996
        slide_img,
1997
        draw_radius,
1998
    )
1999

2000
    # make sure the output gets displayed to the output widget so it displays properly
2001
    with out:
1✔
2002
        # draw the image
2003
        img_plot = ax.imshow(slide_img)
1✔
2004

2005
        # overwrite the default title
2006
        _ = plt.title("Manually-defined to automatically-generated FOV map")
1✔
2007

2008
        # remove massive padding
2009
        _ = plt.tight_layout()
1✔
2010

2011
        # define status of the save annotation, initially None, updates when user clicks w_save
2012
        # NOTE: ipywidget callback functions can only access dicts defined in scope
2013
        save_ann = {"annotation": None}
1✔
2014

2015
        # generate the annotation defining FOV pairings that need extra inspection
2016
        # the user can specify as many of the following:
2017
        # 1. the distance between the manual and auto FOV exceeds check_dist
2018
        # 2. two manual FOVs map to the same auto FOV
2019
        # 3. a manual FOV name does not match its auto FOV name
2020
        manual_auto_warning = generate_validation_annot(
1✔
2021
            manual_to_auto_map,
2022
            manual_auto_dist,
2023
            check_dist,
2024
            check_duplicates,
2025
            check_mismatches,
2026
        )
2027

2028
        # display the errors in the text box
2029
        w_err.value = manual_auto_warning
1✔
2030

2031
    # a callback function for changing w_auto to the value w_man maps to
2032
    # NOTE: needs to be here so it can access w_man and w_auto in scope
2033
    def update_mapping(change):
1✔
2034
        """Updates `w_auto` and bolds a different manual-auto pair when `w_prop` changes.
2035

2036
        Args:
2037
            change (dict):
2038
                defines the properties of the changed value in `w_prop`
2039
        """
2040
        # only operate if w_prop actually changed
2041
        # prevents update if the user drops down w_prop but leaves it as the same manual fov
2042
        if change["name"] == "value" and change["new"] != change["old"]:
×
2043
            # need to be in the output widget context to update
2044
            with out:
×
2045
                new_slide_img = update_mapping_display(
×
2046
                    change,
2047
                    w_auto,
2048
                    manual_to_auto_map,
2049
                    manual_fovs_info,
2050
                    auto_fovs_info,
2051
                    slide_img,
2052
                    draw_radius,
2053
                )
2054

2055
                # set the new slide img in the plot
2056
                img_plot.set_data(new_slide_img)
×
2057
                fig.canvas.draw_idle()
×
2058

2059
    # a callback function for remapping when w_auto changes
2060
    # NOTE: needs to be here so it can access w_man and w_auto in scope
2061
    def remap_values(change):
1✔
2062
        """Bolds the new `w_auto` and maps the selected FOV in `w_man`
2063
        to the new `w_auto` in `manual_to_auto_amp`.
2064

2065
        Args:
2066
            change (dict):
2067
                defines the properties of the changed value in `w_auto`
2068
        """
2069
        # only remap if the auto change as been updated
2070
        # prevents update if the user drops down w_auto but leaves it as the same auto fov
2071
        if change["name"] == "value" and change["new"] != change["old"]:
×
2072
            # need to be in the output widget context to update
2073
            with out:
×
2074
                new_slide_img, manual_auto_warning = remap_manual_to_auto_display(
×
2075
                    change,
2076
                    w_man,
2077
                    manual_to_auto_map,
2078
                    manual_auto_dist,
2079
                    auto_fovs_info,
2080
                    slide_img,
2081
                    draw_radius,
2082
                    check_dist,
2083
                    check_duplicates,
2084
                    check_mismatches,
2085
                )
2086

2087
                # set the new slide img in the plot
2088
                img_plot.set_data(new_slide_img)
×
2089
                fig.canvas.draw_idle()
×
2090

2091
                # re-generate the validation warning in w_err
2092
                w_err.value = manual_auto_warning
×
2093

2094
    # a callback function for saving manual_to_auto_map to mapping_path if w_save clicked
2095
    def save_mapping(b):
1✔
2096
        """Saves the mapping defined in `manual_to_auto_map`.
2097

2098
        Args:
2099
            b (ipywidgets.widgets.widget_button.Button):
2100
                the button handler for `w_save`, only passed as a standard for `on_click` callback
2101
        """
2102
        # need to be in the output widget context to display status
2103
        with out:
×
2104
            # call the helper function to save manual_to_auto_map and notify user
2105
            save_json(manual_to_auto_map, save_ann, mapping_path)
×
2106
            ax.figure.canvas.draw()
×
2107

2108
    # ensure a change to w_man redraws the image due to a new manual fov selected
2109
    w_man.observe(update_mapping)
1✔
2110

2111
    # ensure a change to w_auto redraws the image due to a new automatic fov
2112
    # mapped to the manual fov
2113
    w_auto.observe(remap_values)
1✔
2114

2115
    # if w_save clicked, save the new mapping to the path defined in mapping_path
2116
    w_save.on_click(save_mapping)
1✔
2117

2118
    # display the output
2119
    display(out)
1✔
2120

2121

2122
def remap_and_reorder_fovs(
1✔
2123
    manual_fov_regions,
2124
    manual_to_auto_map,
2125
    moly_path=None,
2126
    randomize=False,
2127
    moly_insert=False,
2128
    moly_interval=5,
2129
):
2130
    """Runs 3 separate tasks on `manual_fov_regions`:
2131
        - Uses `manual_to_auto_map` to rename the FOVs
2132
        - Randomizes the order of the FOVs (if specified)
2133
        - Inserts Moly points at the specified interval (if specified).
2134

2135
    Args:
2136
        manual_fov_regions (dict):
2137
            The list of FOVs proposed by the user
2138
        manual_to_auto_map (dict):
2139
            Defines the mapping of manual to auto FOV names
2140
        moly_path (str):
2141
            The path to the Moly point to insert. Defauls to `None`.
2142
        randomize (bool):
2143
            Whether to randomize the FOVs
2144
        moly_insert (bool):
2145
            Whether to insert Moly points between FOVs at a specified `moly_interval`
2146
        moly_interval (int):
2147
            The interval in which to insert Moly points.
2148
            Ignored if `moly_insert` is `False`.
2149

2150
    Returns:
2151
        dict:
2152
            `manual_fov_regions` with new FOV names, randomized, and with Moly points
2153
    """
2154
    # only load moly_path and verify moly_interval if moly_insert set
2155
    if moly_insert:
1✔
2156
        # file path validation
2157
        if not os.path.exists(moly_path):
1✔
2158
            raise FileNotFoundError("Moly point %s does not exist" % moly_path)
1✔
2159

2160
        # load the Moly point in
2161
        moly_point = json_utils.read_json_file(moly_path, encoding="utf-8")
1✔
2162

2163
        # error check: moly_interval must be a positive integer
2164
        if not isinstance(moly_interval, int) or moly_interval < 1:
1✔
2165
            raise ValueError("moly_interval must be a positive integer")
1✔
2166

2167
    # define a new fov regions dict for remapped names
2168
    remapped_fov_regions = {}
1✔
2169

2170
    # copy over the metadata values from manual_fov_regions to remapped_fov_regions
2171
    remapped_fov_regions = assign_metadata_vals(manual_fov_regions, remapped_fov_regions, ["fovs"])
1✔
2172

2173
    # define a new FOVs list for fov_regions_remapped
2174
    remapped_fov_regions["fovs"] = []
1✔
2175

2176
    # rename the FOVs based on the mapping and append to fov_regions_remapped
2177
    for fov in manual_fov_regions["fovs"]:
1✔
2178
        # needed to prevent early saving since interactive visualization cannot stop this
2179
        # from running if a mapping_path provided already exists
2180
        fov_data = copy.deepcopy(fov)
1✔
2181

2182
        # remap the name and append to fov_regions_remapped
2183
        fov_data["name"] = manual_to_auto_map[fov["name"]]
1✔
2184
        remapped_fov_regions["fovs"].append(fov_data)
1✔
2185

2186
    # randomize the order of the FOVs if specified
2187
    if randomize:
1✔
2188
        remapped_fov_regions["fovs"] = shuffle(remapped_fov_regions["fovs"])
1✔
2189

2190
    # insert Moly points at the specified interval if specified
2191
    if moly_insert:
1✔
2192
        mi = moly_interval
1✔
2193

2194
        while mi < len(remapped_fov_regions["fovs"]):
1✔
2195
            remapped_fov_regions["fovs"].insert(mi, moly_point)
1✔
2196
            mi += moly_interval + 1
1✔
2197

2198
    return remapped_fov_regions
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc