cellects 0.2.6__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -109,7 +109,6 @@ class ImageAnalysisWindow(MainTabsType):
109
109
  self.available_bio_names = np.arange(1, 1000, dtype=np.uint16)
110
110
  self.available_back_names = np.arange(1, 1000, dtype=np.uint16)
111
111
  self.parent().po.current_combination_id = 0
112
- greyscale = len(self.parent().po.first_im.shape) == 2
113
112
 
114
113
  self.display_image = np.zeros((self.parent().im_max_width, self.parent().im_max_width, 3), np.uint8)
115
114
  self.display_image = InsertImage(self.display_image, self.parent().im_max_height, self.parent().im_max_width)
@@ -447,7 +446,8 @@ class ImageAnalysisWindow(MainTabsType):
447
446
  self.thread["GetFirstIm"] = GetFirstImThread(self.parent())
448
447
  self.reinitialize_image_and_masks(self.parent().po.first_im)
449
448
  self.thread["GetLastIm"] = GetLastImThread(self.parent())
450
- self.thread["GetLastIm"].start()
449
+ if self.parent().po.all['im_or_vid'] == 0:
450
+ self.thread["GetLastIm"].start()
451
451
  self.parent().po.first_image = OneImageAnalysis(self.parent().po.first_im)
452
452
  self.thread["FirstImageAnalysis"] = FirstImageAnalysisThread(self.parent())
453
453
  self.thread["LastImageAnalysis"] = LastImageAnalysisThread(self.parent())
@@ -541,7 +541,6 @@ class ImageAnalysisWindow(MainTabsType):
541
541
  self.thread["GetFirstIm"].start()
542
542
  self.thread["GetFirstIm"].message_when_thread_finished.connect(self.reinitialize_image_and_masks)
543
543
  self.reinitialize_bio_and_back_legend()
544
- self.reinitialize_image_and_masks(self.parent().po.first_im)
545
544
 
546
545
 
547
546
  def several_blob_per_arena_check(self):
@@ -1019,6 +1018,7 @@ class ImageAnalysisWindow(MainTabsType):
1019
1018
  self.parent().po.visualize = False
1020
1019
  self.parent().po.basic = False
1021
1020
  self.parent().po.network_shaped = True
1021
+ self.select_option.clear()
1022
1022
  if self.is_first_image_flag:
1023
1023
  self.run_first_image_analysis()
1024
1024
  else:
@@ -1298,11 +1298,13 @@ class ImageAnalysisWindow(MainTabsType):
1298
1298
  self.message.setText("Make sure that scaling metric and spot size are correct")
1299
1299
  else:
1300
1300
  self.parent().po.vars['convert_for_motion'] = im_combinations[self.parent().po.current_combination_id]["csc"]
1301
- if "filter_spec" in im_combinations[self.parent().po.current_combination_id]:
1302
- self.parent().po.vars['filter_spec'] = im_combinations[self.parent().po.current_combination_id]["filter_spec"]
1303
- self.parent().po.vars['rolling_window_segmentation']['do'] = im_combinations[self.parent().po.current_combination_id]["rolling_window"]
1304
- self.update_filter_display()
1305
1301
  self.decision_label.setText("Do colored contours correctly match cell(s) contours?")
1302
+ if "rolling_window" in im_combinations[self.parent().po.current_combination_id]:
1303
+ self.parent().po.vars['rolling_window_segmentation']['do'] = im_combinations[self.parent().po.current_combination_id]["rolling_window"]
1304
+ if "filter_spec" in im_combinations[self.parent().po.current_combination_id]:
1305
+ self.parent().po.vars['filter_spec'] = im_combinations[self.parent().po.current_combination_id][
1306
+ "filter_spec"]
1307
+ self.update_filter_display()
1306
1308
 
1307
1309
  def generate_csc_editing(self):
1308
1310
  """
@@ -1593,10 +1595,13 @@ class ImageAnalysisWindow(MainTabsType):
1593
1595
  def update_filter_display(self):
1594
1596
  self.filter1.setCurrentText(self.parent().po.vars['filter_spec']['filter1_type'])
1595
1597
  self.filter1_param1.setValue(self.parent().po.vars['filter_spec']['filter1_param'][0])
1596
- self.filter1_param2.setValue(self.parent().po.vars['filter_spec']['filter1_param'][1])
1597
- self.filter2.setCurrentText(self.parent().po.vars['filter_spec']['filter2_type'])
1598
- self.filter2_param1.setValue(self.parent().po.vars['filter_spec']['filter2_param'][0])
1599
- self.filter2_param2.setValue(self.parent().po.vars['filter_spec']['filter2_param'][1])
1598
+ if len(self.parent().po.vars['filter_spec']['filter1_param']) > 1:
1599
+ self.filter1_param2.setValue(self.parent().po.vars['filter_spec']['filter1_param'][1])
1600
+ if 'filter2_type' in self.parent().po.vars['filter_spec']:
1601
+ self.filter2.setCurrentText(self.parent().po.vars['filter_spec']['filter2_type'])
1602
+ self.filter2_param1.setValue(self.parent().po.vars['filter_spec']['filter2_param'][0])
1603
+ if len(self.parent().po.vars['filter_spec']['filter2_param']) > 1:
1604
+ self.filter2_param2.setValue(self.parent().po.vars['filter_spec']['filter2_param'][1])
1600
1605
 
1601
1606
  def filter1_changed(self):
1602
1607
  """
@@ -1952,7 +1957,7 @@ class ImageAnalysisWindow(MainTabsType):
1952
1957
  (self.row21[1].value(), self.row21[2].value(), self.row21[3].value()),
1953
1958
  (self.row22[1].value(), self.row22[2].value(), self.row22[3].value()),
1954
1959
  (self.row23[1].value(), self.row23[2].value(), self.row23[3].value())),
1955
- dtype=np.int8)
1960
+ dtype=np.float64)
1956
1961
  if self.logical_operator_between_combination_result.currentText() != 'None':
1957
1962
  spaces = np.concatenate((spaces, np.array((
1958
1963
  self.row21[0].currentText() + "2", self.row22[0].currentText() + "2",
@@ -1960,7 +1965,7 @@ class ImageAnalysisWindow(MainTabsType):
1960
1965
  channels = np.concatenate((channels, np.array(((self.row21[1].value(), self.row21[2].value(), self.row21[3].value()),
1961
1966
  (self.row22[1].value(), self.row22[2].value(), self.row22[3].value()),
1962
1967
  (self.row23[1].value(), self.row23[2].value(), self.row23[3].value())),
1963
- dtype=np.int8)))
1968
+ dtype=np.float64)))
1964
1969
  self.csc_dict['logical'] = self.logical_operator_between_combination_result.currentText()
1965
1970
  else:
1966
1971
  self.csc_dict['logical'] = 'None'
@@ -2057,29 +2062,37 @@ class ImageAnalysisWindow(MainTabsType):
2057
2062
  self.select_option.setVisible(False)
2058
2063
  self.select_option_label.setVisible(False)
2059
2064
 
2060
- def delineate_is_done(self, message: str):
2065
+ def delineate_is_done(self, analysis_status: dict):
2061
2066
  """
2062
2067
  Update GUI after delineation is complete.
2063
2068
  """
2064
- logging.info("Delineation is done, update GUI")
2065
- self.message.setText(message)
2066
- self.arena_shape_label.setVisible(False)
2067
- self.arena_shape.setVisible(False)
2068
- self.reinitialize_bio_and_back_legend()
2069
- self.reinitialize_image_and_masks(self.parent().po.first_image.bgr)
2070
- self.delineation_done = True
2071
- if self.thread["UpdateImage"].isRunning():
2072
- self.thread["UpdateImage"].wait()
2073
- self.thread["UpdateImage"].start()
2074
- self.thread["UpdateImage"].message_when_thread_finished.connect(self.automatic_delineation_display_done)
2069
+ if analysis_status['continue']:
2070
+ logging.info("Delineation is done, update GUI")
2071
+ self.message.setText(analysis_status["message"])
2072
+ self.arena_shape_label.setVisible(False)
2073
+ self.arena_shape.setVisible(False)
2074
+ self.reinitialize_bio_and_back_legend()
2075
+ self.reinitialize_image_and_masks(self.parent().po.first_image.bgr)
2076
+ self.delineation_done = True
2077
+ if self.thread["UpdateImage"].isRunning():
2078
+ self.thread["UpdateImage"].wait()
2079
+ self.thread["UpdateImage"].start()
2080
+ self.thread["UpdateImage"].message_when_thread_finished.connect(self.automatic_delineation_display_done)
2075
2081
 
2076
- try:
2077
- self.thread['CropScaleSubtractDelineate'].message_from_thread.disconnect()
2078
- self.thread['CropScaleSubtractDelineate'].message_when_thread_finished.disconnect()
2079
- except RuntimeError:
2080
- pass
2081
- if not self.slower_delineation_flag:
2082
- self.asking_delineation_flag = True
2082
+ try:
2083
+ self.thread['CropScaleSubtractDelineate'].message_from_thread.disconnect()
2084
+ self.thread['CropScaleSubtractDelineate'].message_when_thread_finished.disconnect()
2085
+ except RuntimeError:
2086
+ pass
2087
+ if not self.slower_delineation_flag:
2088
+ self.asking_delineation_flag = True
2089
+ else:
2090
+ self.delineation_done = False
2091
+ self.asking_delineation_flag = False
2092
+ self.auto_delineation_flag = False
2093
+ self.asking_slower_or_manual_delineation_flag = False
2094
+ self.slower_delineation_flag = False
2095
+ self.manual_delineation()
2083
2096
 
2084
2097
  def automatic_delineation_display_done(self, boole):
2085
2098
  """
@@ -2093,7 +2106,6 @@ class ImageAnalysisWindow(MainTabsType):
2093
2106
  self.auto_delineation_flag = False
2094
2107
  self.select_option_label.setVisible(False)
2095
2108
  self.select_option.setVisible(False)
2096
-
2097
2109
  self.arena_shape_label.setVisible(True)
2098
2110
  self.arena_shape.setVisible(True)
2099
2111
 
@@ -2337,8 +2349,6 @@ class ImageAnalysisWindow(MainTabsType):
2337
2349
  self.yes.setVisible(True)
2338
2350
  self.cell.setVisible(False)
2339
2351
  self.background.setVisible(False)
2340
- self.arena_shape_label.setVisible(False)
2341
- self.arena_shape.setVisible(False)
2342
2352
  self.no.setVisible(False)
2343
2353
  self.one_blob_per_arena.setVisible(False)
2344
2354
  self.one_blob_per_arena_label.setVisible(False)
@@ -657,8 +657,9 @@ AP["Specimens_have_same_direction"]["label"] = "All specimens have the same dire
657
657
  AP["Specimens_have_same_direction"]["tips"] = \
658
658
  f"""Select to optimize arena detection for specimens moving move in the same direction.
659
659
  - **Checked** → Uses motion pattern analysis for arena localization.
660
- - **Unchecked** → Employs standard centroid
661
- -based algorithm.
660
+ - **Unchecked** → Employs standard centroid based algorithm.
661
+ NB:
662
+ - Both options work equally when growth is roughly isotropic.
662
663
  """
663
664
  # END_TIP
664
665
 
@@ -46,7 +46,7 @@ filter_dict = {"": {'': {}},
46
46
  'Param2': {'Name': 'Sigma max:', 'Minimum': 0., 'Maximum': 1000., 'Default': 10.}},
47
47
  "Hessian": {'Param1': {'Name': 'Sigma min:', 'Minimum': 0., 'Maximum': 1000., 'Default': 1.},
48
48
  'Param2': {'Name': 'Sigma max:', 'Minimum': 0., 'Maximum': 1000., 'Default': 10.}},
49
- "Laplace": {'Param1': {'Name': 'Ksize:', 'Minimum': 0., 'Maximum': 100., 'Default': 3}},
49
+ "Laplace": {'Param1': {'Name': 'Ksize:', 'Minimum': 3, 'Maximum': 100, 'Default': 5}},
50
50
  "Sharpen": {'': {}},
51
51
  "Mexican hat": {'': {}},
52
52
  "Farid": {'': {}},
@@ -142,7 +142,7 @@ def apply_filter(image: NDArray, filter_type: str, param, rescale_to_uint8=False
142
142
  elif filter_type == "Hessian":
143
143
  image = hessian(image, sigmas=np.linspace(param[0], param[1], num=3))
144
144
  elif filter_type == "Laplace":
145
- image = laplace(image, ksize=int(param[0]))
145
+ image = laplace(image, ksize=np.max((3, int(np.ceil(param[0])))))
146
146
  elif filter_type == "Sharpen":
147
147
  image = cv2.filter2D(image, -1, np.array([[-1, -1, -1], [-1, 9, -1], [-1, -1, -1]]))
148
148
  elif filter_type == "Mexican hat":
@@ -339,65 +339,6 @@ def generate_color_space_combination(bgr_image: NDArray[np.uint8], c_spaces: lis
339
339
  return greyscale_image, greyscale_image2, all_c_spaces, first_pc_vector
340
340
 
341
341
 
342
- @njit()
343
- def get_window_allowed_for_segmentation(im_shape: Tuple, mask:NDArray[np.uint8]=None, padding: int=0) -> Tuple[int, int, int, int]:
344
- """
345
- Get the allowed window for segmentation within an image.
346
-
347
- This function calculates a bounding box (min_y, max_y, min_x, max_x) around
348
- a segmentation mask or the entire image if no mask is provided.
349
-
350
- Parameters
351
- ----------
352
- im_shape : Tuple[int, int]
353
- The shape of the image (height, width).
354
- mask : NDArray[np.uint8], optional
355
- The binary mask for segmentation. Default is `None`.
356
- padding : int, optional
357
- Additional padding around the bounding box. Default is 0.
358
-
359
- Returns
360
- -------
361
- Tuple[int, int, int, int]
362
- A tuple containing the bounding box coordinates (min_y, max_y,
363
- min_x, max_x).
364
-
365
- Notes
366
- -----
367
- This function uses NumPy operations to determine the bounding box.
368
- If `mask` is `None`, the full image dimensions are used.
369
-
370
- Examples
371
- --------
372
- >>> im_shape = (500, 400)
373
- >>> mask = np.zeros((500, 400), dtype=np.uint8)
374
- >>> mask[200:300, 200:300] = 1
375
- >>> result = get_window_allowed_for_segmentation(im_shape, mask, padding=10)
376
- >>> print(result)
377
- (190, 310, 190, 310)
378
-
379
- >>> result = get_window_allowed_for_segmentation(im_shape)
380
- >>> print(result)
381
- (0, 500, 0, 400)
382
- """
383
- if mask is None or mask.sum() == 0:
384
- min_y = 0
385
- min_x = 0
386
- max_y = im_shape[0]
387
- max_x = im_shape[1]
388
- else:
389
- y, x = np.nonzero(mask)
390
- min_y = np.min(y)
391
- min_y = np.max((min_y - padding, 0))
392
- min_x = np.min(x)
393
- min_x = np.max((min_x - padding, 0))
394
- max_y = np.max(y)
395
- max_y = np.min((max_y + padding + 1, mask.shape[0]))
396
- max_x = np.max(x)
397
- max_x = np.min((max_x + padding + 1, mask.shape[0]))
398
- return min_y, max_y, min_x, max_x
399
-
400
-
401
342
  @njit()
402
343
  def get_otsu_threshold(image: NDArray):
403
344
  """
@@ -479,11 +420,10 @@ def otsu_thresholding(image: NDArray) -> NDArray[np.uint8]:
479
420
  """
480
421
  threshold = get_otsu_threshold(image)
481
422
  binary_image = (image > threshold)
482
- binary_image2 = np.logical_not(binary_image)
483
- if binary_image.sum() < binary_image2.sum():
423
+ if binary_image.sum() < binary_image.size / 2:
484
424
  return binary_image.astype(np.uint8)
485
425
  else:
486
- return binary_image2.astype(np.uint8)
426
+ return np.logical_not(binary_image).astype(np.uint8)
487
427
 
488
428
 
489
429
  def segment_with_lum_value(converted_video: NDArray, basic_bckgrnd_values: NDArray, l_threshold, lighter_background: bool) -> Tuple[NDArray, NDArray]:
@@ -554,7 +494,7 @@ def segment_with_lum_value(converted_video: NDArray, basic_bckgrnd_values: NDArr
554
494
 
555
495
 
556
496
  def kmeans(greyscale: NDArray, greyscale2: NDArray=None, kmeans_clust_nb: int=2,
557
- biomask: NDArray[np.uint8]=None, backmask: NDArray[np.uint8]=None, logical: str='None',
497
+ bio_mask: NDArray[np.uint8]=None, back_mask: NDArray[np.uint8]=None, logical: str='None',
558
498
  bio_label=None, bio_label2=None, previous_binary_image: NDArray[np.uint8]=None):
559
499
  """
560
500
 
@@ -572,9 +512,9 @@ def kmeans(greyscale: NDArray, greyscale2: NDArray=None, kmeans_clust_nb: int=2,
572
512
  A second greyscale image for logical operations. Default is `None`.
573
513
  kmeans_clust_nb : int, optional
574
514
  Number of clusters for K-means. Default is `2`.
575
- biomask : NDArray[np.uint8], optional
515
+ bio_mask : NDArray[np.uint8], optional
576
516
  Mask for selecting biological objects. Default is `None`.
577
- backmask : NDArray[np.uint8], optional
517
+ back_mask : NDArray[np.uint8], optional
578
518
  Mask for selecting background regions. Default is `None`.
579
519
  logical : str, optional
580
520
  Logical operation flag to enable processing of the second image. Default is `'None'`.
@@ -605,6 +545,10 @@ def kmeans(greyscale: NDArray, greyscale2: NDArray=None, kmeans_clust_nb: int=2,
605
545
  - Default clustering uses 2 clusters, modify `kmeans_clust_nb` for different needs.
606
546
 
607
547
  """
548
+ if isinstance(bio_mask, np.ndarray):
549
+ bio_mask = np.nonzero(bio_mask)
550
+ if isinstance(back_mask, np.ndarray):
551
+ back_mask = np.nonzero(back_mask)
608
552
  new_bio_label = None
609
553
  new_bio_label2 = None
610
554
  binary_image2 = None
@@ -628,13 +572,13 @@ def kmeans(greyscale: NDArray, greyscale2: NDArray=None, kmeans_clust_nb: int=2,
628
572
  binary_image[kmeans_image == bio_label] = 1
629
573
  new_bio_label = bio_label
630
574
  else:
631
- if biomask is not None:
632
- all_labels = kmeans_image[biomask[0], biomask[1]]
575
+ if bio_mask is not None:
576
+ all_labels = kmeans_image[bio_mask[0], bio_mask[1]]
633
577
  for i in range(kmeans_clust_nb):
634
578
  sum_per_label[i] = (all_labels == i).sum()
635
579
  new_bio_label = np.argsort(sum_per_label)[1]
636
- elif backmask is not None:
637
- all_labels = kmeans_image[backmask[0], backmask[1]]
580
+ elif back_mask is not None:
581
+ all_labels = kmeans_image[back_mask[0], back_mask[1]]
638
582
  for i in range(kmeans_clust_nb):
639
583
  sum_per_label[i] = (all_labels == i).sum()
640
584
  new_bio_label = np.argsort(sum_per_label)[-2]
@@ -642,9 +586,9 @@ def kmeans(greyscale: NDArray, greyscale2: NDArray=None, kmeans_clust_nb: int=2,
642
586
  for i in range(kmeans_clust_nb):
643
587
  sum_per_label[i] = (kmeans_image == i).sum()
644
588
  new_bio_label = np.argsort(sum_per_label)[-2]
645
- binary_image[np.nonzero(np.isin(kmeans_image, new_bio_label))] = 1
589
+ binary_image += np.isin(kmeans_image, new_bio_label)
646
590
 
647
- if logical != 'None' and greyscale is not None:
591
+ if logical != 'None' and greyscale2 is not None:
648
592
  image = greyscale2.reshape((-1, 1))
649
593
  image = np.float32(image)
650
594
  criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
@@ -666,13 +610,13 @@ def kmeans(greyscale: NDArray, greyscale2: NDArray=None, kmeans_clust_nb: int=2,
666
610
  binary_image2[kmeans_image == bio_label2] = 1
667
611
  new_bio_label2 = bio_label2
668
612
  else:
669
- if biomask is not None:
670
- all_labels = kmeans_image[biomask[0], biomask[1]]
613
+ if bio_mask is not None:
614
+ all_labels = kmeans_image[bio_mask[0], bio_mask[1]]
671
615
  for i in range(kmeans_clust_nb):
672
616
  sum_per_label[i] = (all_labels == i).sum()
673
617
  new_bio_label2 = np.argsort(sum_per_label)[1]
674
- elif backmask is not None:
675
- all_labels = kmeans_image[backmask[0], backmask[1]]
618
+ elif back_mask is not None:
619
+ all_labels = kmeans_image[back_mask[0], back_mask[1]]
676
620
  for i in range(kmeans_clust_nb):
677
621
  sum_per_label[i] = (all_labels == i).sum()
678
622
  new_bio_label2 = np.argsort(sum_per_label)[-2]
@@ -646,9 +646,9 @@ def get_line_points(start, end) -> NDArray[int]:
646
646
  Parameters
647
647
  ----------
648
648
  start : tuple of int
649
- The starting point coordinates (x0, y0).
649
+ The starting point coordinates (y0, x0).
650
650
  end : tuple of int
651
- The ending point coordinates (x1, y1).
651
+ The ending point coordinates (y1, x1).
652
652
 
653
653
  Returns
654
654
  -------
@@ -1457,7 +1457,7 @@ def dynamically_expand_to_fill_holes(binary_video: NDArray[np.uint8], holes: NDA
1457
1457
 
1458
1458
 
1459
1459
  @njit()
1460
- def create_ellipse(vsize_in, hsize_in):
1460
+ def create_ellipse(vsize: int, hsize: int, min_size: int=0) -> NDArray[np.uint8]:
1461
1461
  """
1462
1462
  Create a 2D array representing an ellipse with given vertical and horizontal sizes.
1463
1463
 
@@ -1467,9 +1467,9 @@ def create_ellipse(vsize_in, hsize_in):
1467
1467
 
1468
1468
  Parameters
1469
1469
  ----------
1470
- vsize_in : int
1470
+ vsize : int
1471
1471
  Vertical size (number of rows) in the output 2D array.
1472
- hsize_in : int
1472
+ hsize : int
1473
1473
  Horizontal size (number of columns) in the output 2D array.
1474
1474
 
1475
1475
  Returns
@@ -1477,14 +1477,10 @@ def create_ellipse(vsize_in, hsize_in):
1477
1477
  NDArray[bool]
1478
1478
  A boolean NumPy array of shape `(vsize, hsize)` where `True` indicates that a pixel lies within or on
1479
1479
  the boundary of an ellipse centered at the image's center with radii determined by half of the dimensions.
1480
-
1481
- Notes
1482
- -----
1483
- If either vertical or horizontal size is zero, it defaults to 3 as in the original class behavior.
1484
1480
  """
1485
1481
  # Use default values if input sizes are zero
1486
- vsize = 3 if vsize_in == 0 else vsize_in
1487
- hsize = 3 if hsize_in == 0 else hsize_in
1482
+ vsize = min_size if vsize == 0 else vsize
1483
+ hsize = min_size if hsize == 0 else hsize
1488
1484
 
1489
1485
  # Compute radii (half of each size)
1490
1486
  vr = hsize // 2
@@ -1499,7 +1495,7 @@ def create_ellipse(vsize_in, hsize_in):
1499
1495
  lhs = ((x - hr) ** 2 / (hr ** 2)) + ((y - vr) ** 2 / (vr ** 2))
1500
1496
  result[i, j] = lhs <= 1
1501
1497
  else:
1502
- result[0, 0] = True
1498
+ result[hr, vr] = True
1503
1499
  return result
1504
1500
 
1505
1501
  rhombus_55 = create_ellipse(5, 5).astype(np.uint8)
@@ -1690,7 +1686,7 @@ def get_bb_with_moving_centers(motion_list: list, all_specimens_have_same_direct
1690
1686
  k_size = 3
1691
1687
  if original_shape_hsize is not None:
1692
1688
  k_size = int((np.ceil(original_shape_hsize / 5) * 2) + 1)
1693
- big_kernel = create_ellipse(k_size, k_size).astype(np.uint8)
1689
+ big_kernel = create_ellipse(k_size, k_size, min_size=3).astype(np.uint8)
1694
1690
 
1695
1691
  ordered_stats, ordered_centroids, ordered_image = rank_from_top_to_bottom_from_left_to_right(
1696
1692
  binary_image, y_boundaries, get_ordered_image=True)