-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsize_it_cs.py
3139 lines (2652 loc) · 134 KB
/
size_it_cs.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
A tkinter GUI, size_it_cs.py, for OpenCV processing of an image to obtain
sizes, means, and ranges of objects in a sample population. Object
segmentation is by use of a matte color screen ('cs'), such as with a
green screen. Different matte colors can be selected. Noise reduction
is interactive with live updating of resulting images.
A report is provided of parameter settings, object count, individual
object sizes, and sample size mean and range, along with an annotated
image file of labeled objects.
USAGE
For command line execution, from within the count-and-size-main folder:
python3 -m size_it_cs --about
python3 -m size_it_cs --help
python3 -m size_it_cs
python3 -m size_it_cs --terminal
Windows systems may need to substitute 'python3' with 'py' or 'python'.
Commands for saving results and settings, adjusting images' screen
size, and annotation styles are available from the "Report & Settings"
window's menubar and buttons.
Quit program with Esc key, Ctrl-Q key, the close window icon of the
report window or File menubar. From the command line, use Ctrl-C.
Requires Python 3.7 or later and the packages opencv-python, numpy,
scikit-image, scipy, and psutil.
See this distribution's requirements.txt file for details.
Developed in Python 3.8 and 3.9, tested up to 3.11.
"""
# Copyright (C) 2024 C.S. Echt, under GNU General Public License
# Standard library imports.
from datetime import datetime
from json import loads
from pathlib import Path
from signal import signal, SIGINT
from statistics import mean, median
from sys import exit as sys_exit
from time import time
from typing import Union, List
# Third party imports.
# tkinter(Tk/Tcl) is included with most Python3 distributions,
# but may sometimes need to be regarded as third-party.
# There is a bug(?) in PyCharm that does not recognize cv2 memberships,
# so pylint and inspections flag every use of cv2.*.
# Be aware that this disables all checks of (E1101): *%s %r has no %r member%s*
# pylint: disable=no-member
try:
import cv2
import numpy as np
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from skimage.segmentation import watershed
from skimage.feature import peak_local_max
from scipy import ndimage
except (ImportError, ModuleNotFoundError) as import_err:
sys_exit(
'*** One or more required Python packages were not found'
' or need an update:\nOpenCV-Python, NumPy, scikit-image, SciPy, tkinter (Tk/Tcl).\n\n'
'To install: from the current folder, run this command'
' for the Python package installer (PIP):\n'
' python3 -m pip install -r requirements.txt\n\n'
'Alternative command formats (system dependent):\n'
' py -m pip install -r requirements.txt (Windows)\n'
' pip install -r requirements.txt\n\n'
'You may also install directly using, for example, this command,'
' for the Python package installer (PIP):\n'
' python3 -m pip install opencv-python\n\n'
'A package may already be installed, but needs an update;\n'
' this may be the case when the error message (below) is a bit cryptic\n'
' Example update command:\n'
' python3 -m pip install -U numpy\n\n'
'On Linux, if tkinter is the problem, then you may need:\n'
' sudo apt-get install python3-tk\n\n'
'See also: https://numpy.org/install/\n'
' https://tkdocs.com/tutorial/install.html\n'
' https://docs.opencv2.org/4.6.0/d5/de5/tutorial_py_setup_in_windows.html\n\n'
'Consider running this app and installing missing packages in a virtual environment.\n'
f'Error message:\n{import_err}')
# Local application imports.
# pylint: disable=import-error
# Need to place local imports after try...except to ensure exit messaging.
from utility_modules import (vcheck,
utils,
manage,
constants as const,
to_precision as to_p)
PROGRAM_NAME = utils.program_name()
class ProcessImage(tk.Tk):
"""
A suite of OpenCV methods to apply various image processing
functions involved in segmenting objects from an image file.
Class methods:
update_image
reduce_noise
matte_segmentation
watershed_segmentation
"""
def __init__(self):
super().__init__()
# Note: The matching selector widgets for the following
# control variables are in ContourViewer __init__.
self.slider_val = {
# For Scale() widgets in config_sliders()...
'noise_k': tk.IntVar(),
'noise_iter': tk.IntVar(),
'circle_r_min': tk.IntVar(),
'circle_r_max': tk.IntVar(),
# For Scale() widgets in setup_watershed_window()...
'plm_mindist': tk.IntVar(),
'plm_footprint': tk.IntVar(),
}
self.scale_factor = tk.DoubleVar()
self.cbox_val = {
# For textvariables in config_comboboxes()...
'morph_op': tk.StringVar(),
'morph_shape': tk.StringVar(),
'size_std': tk.StringVar(),
# For setup_start_window()...
'annotation_color': tk.StringVar(),
'matte_color': tk.StringVar(),
}
# Arrays of images to be processed. When used within a method,
# the purpose of self.tkimg[*] as an instance attribute is to
# retain the attribute reference and thus prevent garbage collection.
# Dict values will be defined for panels of PIL ImageTk.PhotoImage
# with Label images displayed in their respective tkimg_window Toplevel.
# The cvimg images are numpy arrays.
self.tkimg: dict = {}
self.cvimg: dict = {}
for _name in const.CS_IMAGE_NAMES:
self.tkimg[_name] = tk.PhotoImage()
self.cvimg[_name] = const.STUB_ARRAY
# img_label dictionary is set up in SetupApp.setup_image_windows(),
# but is used in all Class methods here.
self.img_label: dict = {}
# metrics dict is populated in SetupApp.open_input().
self.metrics: dict = {}
self.line_thickness: int = 0
self.font_scale: float = 0
self.matte_contours: tuple = ()
self.watershed_basins: tuple = ()
self.sorted_size_list: list = []
self.unit_per_px = tk.DoubleVar()
self.num_sigfig: int = 0
self.time_start: float = 0
self.elapsed: Union[float, int, str] = 0
def update_image(self, image_name: str) -> None:
"""
Process a cv2 image array to use as a tk PhotoImage and update
(configure) its window label for immediate display, at scale.
Calls module manage.tk_image(). Called from all methods that
display an image.
Args:
image_name: An item name used in the image_name tuple, for
use as key in tkimg, cvimg, and img_label dictionaries.
Returns:
None
"""
self.tkimg[image_name] = manage.tk_image(
image=self.cvimg[image_name],
scale_factor=self.scale_factor.get()
)
self.img_label[image_name].configure(image=self.tkimg[image_name])
def reduce_noise(self, img: np.ndarray) -> None:
"""
Reduce noise in the matte mask image using erode and dilate
actions of cv2.morphologyEx operations.
Called by matte_segmentation(). Calls update_image().
Args:
img: The color matte mask from matte_segmentation().
Returns: None
"""
iteration = self.slider_val['noise_iter'].get()
if iteration == 0:
self.cvimg['redux_mask'] = img
self.update_image(image_name='redux_mask')
return
noise_k = self.slider_val['noise_k'].get()
# Need integers for the cv function parameters.
morph_shape = const.CV['morph_shape'][self.cbox_val['morph_shape'].get()]
morph_op = const.CV['morph_op'][self.cbox_val['morph_op'].get()]
# See: https://docs.opencv2.org/3.0-beta/modules/imgproc/doc/filtering.html
# on page, see: cv2.getStructuringElement(shape, ksize[, anchor])
# see: https://docs.opencv2.org/4.x/d9/d61/tutorial_py_morphological_ops.html
element = cv2.getStructuringElement(
shape=morph_shape,
ksize=(noise_k, noise_k))
# Use morphologyEx as a shortcut for erosion followed by dilation.
# Read https://docs.opencv2.org/3.4/db/df6/tutorial_erosion_dilatation.html
# https://theailearner.com/tag/cv-morphologyex/
# The op argument from const.CV['morph_op'] options:
# MORPH_OPEN is useful to remove noise and small features.
# MORPH_CLOSE is better for certain images, but generally is worse.
# MORPH_HITMISS helps to separate close objects by shrinking them.
# morph_op HITMISS works best for colorseg, with a low kernel size.
# cvimg['redux_mask'] is used in matte_segmentation() and watershed_segmentation().
self.cvimg['redux_mask'] = cv2.morphologyEx(
src=img,
op=morph_op,
kernel=element,
iterations=iteration,
borderType=cv2.BORDER_DEFAULT,
)
self.update_image(image_name='redux_mask')
def matte_segmentation(self) -> None:
"""
An optional segmentation method to use on color matte masks,
e.g., green screen.
Returns: None
"""
# Convert the input image to HSV colorspace for better color segmentation.
hsv_img = cv2.cvtColor(src=self.cvimg['input'], code=cv2.COLOR_BGR2HSV)
# see: https://stackoverflow.com/questions/47483951/
# how-can-i-define-a-threshold-value-to-detect-only-green-colour-objects-in-an-ima
# /47483966#47483966
# Dict values are the lower and upper (light & dark)
# BGR colorspace range boundaries to use for HSV color discrimination.
# Note that cv2.inRange thresholds all elements within the
# color bounds to white and everything else to black.
lower, upper = const.MATTE_COLOR_RANGE[self.cbox_val['matte_color'].get()]
matte_mask = cv2.inRange(src=hsv_img, lowerb=lower, upperb=upper)
# Run the mask through noise reduction, then use inverse of image for
# finding matte_objects contours.
self.reduce_noise(matte_mask)
self.cvimg['matte_objects'] = cv2.bitwise_not(self.cvimg['redux_mask'])
# matte_contours is used in select_and_size_objects() and select_and_export_objects(),
# where selected contours are used to draw and size enclosing circles or
# draw and export the ROI.
# The data type is a tuple of lists of contour pointsets.
self.matte_contours, _ = cv2.findContours(image=np.uint8(self.cvimg['matte_objects']),
mode=cv2.RETR_EXTERNAL,
method=cv2.CHAIN_APPROX_NONE)
self.cvimg['matte_objects'] = cv2.cvtColor(src=self.cvimg['matte_objects'],
code=cv2.COLOR_GRAY2BGR)
# Need to avoid contour colors that cannot be seen well against
# a black or white background.
if self.cbox_val['annotation_color'].get() in 'white, black, dark blue':
line_color = const.COLORS_CV['orange']
else:
line_color = const.COLORS_CV[self.cbox_val['annotation_color'].get()]
cv2.drawContours(image=self.cvimg['matte_objects'],
contours=self.matte_contours,
contourIdx=-1, # do all contours
color=line_color,
thickness=self.line_thickness,
lineType=cv2.LINE_AA)
self.update_image(image_name='matte_objects')
# Now need to draw enclosing circles around selected segments and
# annotate with object sizes in ViewImage.select_and_size_objects().
def watershed_segmentation(self) -> None:
"""
Separate groups of objects in a matte mask. Inverts the noise-reduced
mask, then applies a distance transform and watershed algorithm
to separate objects that are touching. Intended for use when the
color matte is not sufficient for complete object separation.
Returns: None
"""
# Calculate the distance transform of the objects' masks by
# replacing each foreground (non-zero) element with its
# shortest distance to the background (any zero-valued element).
# Returns a float64 ndarray.
# Note that maskSize=0 calculates the precise mask size only for cv2.DIST_L2.
# cv2.DIST_L1 and cv2.DIST_C always use maskSize=3.
inv_img = cv2.bitwise_not(self.cvimg['redux_mask'])
transformed: np.ndarray = cv2.distanceTransform(
src=inv_img,
distanceType=cv2.DIST_L2,
maskSize=0
)
p_kernel: tuple = (self.slider_val['plm_footprint'].get(),
self.slider_val['plm_footprint'].get())
local_max: ndimage = peak_local_max(
image=transformed,
min_distance=self.slider_val['plm_mindist'].get(),
exclude_border=False, # True uses min_distance.
num_peaks=np.inf,
footprint=np.ones(shape=p_kernel, dtype=np.uint8),
labels=inv_img,
num_peaks_per_label=np.inf,
p_norm=np.inf, # Chebyshev distance
)
mask = np.zeros(shape=transformed.shape, dtype=bool)
# Set background to True (not zero: True or 1)
mask[tuple(local_max.T)] = True
# Note that markers are single px, colored in grayscale by their label index.
# Source: http://scipy-lectures.org/packages/scikit-image/index.html
# From the doc: labels: array of ints, of same shape as data without channels dimension.
# Array of seed markers labeled with different positive integers for
# different phases. Zero-labeled pixels are unlabeled pixels.
# Negative labels correspond to inactive pixels that are not taken into
# account (they are removed from the graph).
labeled_array, _ = ndimage.label(input=mask)
labeled_array[labeled_array == inv_img] = -1
# Note that the minus symbol with the image argument converts the
# distance transform into a threshold. Watershed can work without
# that conversion, but does a better job identifying segments with it.
# https://scikit-image.org/docs/stable/auto_examples/segmentation/plot_compact_watershed.html
# https://scikit-image.org/docs/stable/auto_examples/segmentation/plot_watershed.html
# compactness=1.0 based on: DOI:10.1109/ICPR.2014.181
# Watershed_line=True is necessary to separate touching objects.
watershed_img: np.ndarray = watershed(
image=-transformed,
markers=labeled_array,
connectivity=4,
mask=inv_img,
compactness=1.0,
watershed_line=True
)
# watershed_basins are the contours to be passed to select_and_size_objects(),
# where selected contours are used to draw and size enclosing circles.
# The data type is a tuple of lists of contour pointsets.
self.watershed_basins, _ = cv2.findContours(
image=np.uint8(watershed_img),
mode=cv2.RETR_EXTERNAL,
method=cv2.CHAIN_APPROX_NONE,
)
class ViewImage(ProcessImage):
"""
A suite of methods to display cv segments based on selected settings
and parameters that are in ProcessImage() methods.
Methods:
set_auto_scale_factor
import_settings
delay_size_std_info_msg
show_info_message
configure_circle_r_sliders
widget_control
noise_widget_control
validate_px_size_entry
validate_custom_size_entry
set_size_standard
is_selected_contour
measure_object
annotate_object
select_and_size_objects
preview_export
mask_for_export
define_roi
select_and_export_objects
report_results
process_ws
process_matte
process_sizes
"""
def __init__(self):
super().__init__()
self.first_run: bool = True
self.report_frame = tk.Frame()
self.selectors_frame = tk.Frame()
# self.configure(bg='green') # for development.
# The control variables with matching names for these Scale() and
# Combobox() widgets are instance attributes in ProcessImage.
# plm_mindist and plm_footprint are for watershed segmentation.
# 'plm' is for peak_local_max, a function from skimage.feature.
self.slider = {
'noise_k': tk.Scale(master=self.selectors_frame),
'noise_k_lbl': tk.Label(master=self.selectors_frame),
'noise_iter': tk.Scale(master=self.selectors_frame),
'noise_iter_lbl': tk.Label(master=self.selectors_frame),
'circle_r_min': tk.Scale(master=self.selectors_frame),
'circle_r_min_lbl': tk.Label(master=self.selectors_frame),
'circle_r_max': tk.Scale(master=self.selectors_frame),
'circle_r_max_lbl': tk.Label(master=self.selectors_frame),
'plm_mindist': tk.Scale(),
'plm_mindist_lbl': tk.Label(),
'plm_footprint': tk.Scale(),
'plm_footprint_lbl': tk.Label(),
}
self.cbox = {
'morph_op': ttk.Combobox(master=self.selectors_frame),
'morph_op_lbl': tk.Label(master=self.selectors_frame),
'morph_shape': ttk.Combobox(master=self.selectors_frame),
'morph_shape_lbl': tk.Label(master=self.selectors_frame),
'size_std': ttk.Combobox(master=self.selectors_frame),
'size_std_lbl': tk.Label(master=self.selectors_frame),
'matte_color': ttk.Combobox(master=self.selectors_frame),
'matte_lbl': tk.Label(master=self.selectors_frame),
}
self.size_std = {
'px_entry': tk.Entry(master=self.selectors_frame),
'px_val': tk.StringVar(master=self.selectors_frame),
'px_lbl': tk.Label(master=self.selectors_frame),
'custom_entry': tk.Entry(master=self.selectors_frame),
'custom_val': tk.StringVar(master=self.selectors_frame),
'custom_lbl': tk.Label(master=self.selectors_frame),
}
self.button = {
'process_matte': ttk.Button(master=self),
'save_results': ttk.Button(master=self),
'new_input': ttk.Button(master=self),
'export_objects': ttk.Button(master=self),
'export_settings': ttk.Button(master=self),
'reset': ttk.Button(master=self),
}
# Screen pixel width is defined in setup_main_window().
self.screen_width: int = 0
# Info label is gridded in configure_main_window().
self.info_txt = tk.StringVar()
self.info_label = tk.Label(master=self, textvariable=self.info_txt)
# Defined in widget_control() to reset values that user may have
# tried to change during prolonged processing times.
self.slider_values: list = []
# The following group of attributes is set in SetupApp.open_input().
self.input_file_path: str = ''
self.input_file_name: str = ''
self.input_folder_name: str = ''
self.input_folder_path: str = ''
self.input_ht: int = 0
self.input_w: int = 0
self.settings_file_path = Path('')
self.use_saved_settings: bool = False
self.imported_settings: dict = {}
# The following group of attributes is set in select_and_size_objects().
self.num_objects_selected: int = 0
self.selected_sizes: List[float] = []
self.object_labels: List[list] = []
self.report_txt: str = ''
# This window, used to display the watershed segmentation controllers,
# is defined in SetupApp.setup_watershed_window().
self.watershed_window = None
def set_auto_scale_factor(self) -> None:
"""
As a convenience for user, set a default scale factor to that
needed for images to fit easily on the screen, either 1/3
screen px width or 2/3 screen px height, depending
on input image orientation.
Called from open_input() and _Command.apply_default_settings().
Returns: None
"""
# Note that the scale factor is not included in saved_settings.json.
if self.input_w >= self.input_ht:
estimated_scale = round((self.screen_width * 0.33) / self.input_w, 2)
else:
estimated_scale = round((self.winfo_screenheight() * 0.66) / self.input_ht, 2)
self.scale_factor.set(estimated_scale)
def import_settings(self) -> None:
"""
Uses a dictionary of saved settings, imported via json.loads(),
that are to be applied to the input image. Includes all settings
except the scale_factor for window image size.
Called from check_for_saved_settings(), set_cololr_defaults().
Returns: None
"""
try:
with open(self.settings_file_path, mode='rt', encoding='utf-8') as _fp:
settings_json = _fp.read()
self.imported_settings: dict = loads(settings_json)
except FileNotFoundError as fnf:
print('The settings JSON file could not be found.\n'
f'{fnf}')
except OSError as oserr:
print('There was a problem reading the settings JSON file.\n'
f'{oserr}')
# Set/Reset Scale widgets.
for _name in self.slider_val:
self.slider_val[_name].set(self.imported_settings[_name])
# Set/Reset Combobox widgets.
for _name in self.cbox_val:
self.cbox_val[_name].set(self.imported_settings[_name])
self.font_scale = self.imported_settings['font_scale']
self.line_thickness = self.imported_settings['line_thickness']
self.size_std['px_val'].set(self.imported_settings['px_val'])
self.size_std['custom_val'].set(self.imported_settings['custom_val'])
def delay_size_std_info_msg(self) -> None:
"""
When no size standard values are entered display, after a few
seconds, size standard instructions in the mainloop (app)
window.
Called from process_ws(), process_matte(), process_sizes(), and
configure_buttons._new_input().
Internal function calls show_info_message().
Returns: None
"""
def _show_msg() -> None:
_info = ('\nWhen entered pixel size is 1 AND size standard is "None",\n'
'then size units are pixels.\n'
'Size units are millimeters for any pre-set size standard.\n'
f'(Processing time elapsed: {self.elapsed})\n')
self.show_info_message(info=_info, color='black')
if (self.size_std['px_val'].get() == '1' and
self.cbox_val['size_std'].get() == 'None'):
self.after(ms=7777, func=_show_msg)
def show_info_message(self, info: str, color: str) -> None:
"""
Configure for display and update the informational message in
the report and settings window.
Args:
info: The text string of the message to display.
color: The font color string, either as a key in the
const.COLORS_TK dictionary or as a Tk compatible fg
color string, i.e. hex code or X11 named color.
Returns: None
"""
self.info_txt.set(info)
# Need to handle cases when color is defined as a dictionary key,
# hex code, or X11 named color.
try:
tk_color = const.COLORS_TK[color]
except KeyError:
tk_color = color
self.info_label.config(fg=tk_color)
def configure_circle_r_sliders(self) -> None:
"""
Adjust the Scale() widgets for circle radius min and max based
on the input image size.
Called from config_sliders() and open_input().
Returns: None
"""
# Note: this widget configuration method is here, instead of in
# SetupApp() b/c it is called from open_input() as well as from
# config_sliders().
# Note: may need to adjust circle_r_min scaling with image size b/c
# large contours cannot be selected if circle_r_max is too small.
min_circle_r = self.metrics['max_circle_r'] // 6
max_circle_r = self.metrics['max_circle_r']
self.slider['circle_r_min'].configure(
from_=1, to=min_circle_r,
tickinterval=min_circle_r / 10,
variable=self.slider_val['circle_r_min'],
**const.SCALE_PARAMETERS)
self.slider['circle_r_max'].configure(
from_=1, to=max_circle_r,
tickinterval=max_circle_r / 10,
variable=self.slider_val['circle_r_max'],
**const.SCALE_PARAMETERS)
def widget_control(self, action: str) -> None:
"""
Used to disable settings widgets when segmentation is running.
Provides a watch cursor while widgets are disabled.
Gets Scale() values at time of disabling and resets them upon
enabling, thus preventing user click events retained in memory
during processing from changing slider position post-processing.
Args:
action: Either 'off' to disable widgets, or 'on' to enable.
Returns:
None
"""
if action == 'off':
for _name, _w in self.slider.items():
_w.configure(state=tk.DISABLED)
# Grab the current slider values, in case user tries to change.
if isinstance(_w, tk.Scale):
self.slider_values.append(self.slider_val[_name].get())
for _, _w in self.cbox.items():
_w.configure(state=tk.DISABLED)
for _, _w in self.button.items():
_w.grid_remove()
for _, _w in self.size_std.items():
if not isinstance(_w, tk.StringVar):
_w.configure(state=tk.DISABLED)
self.config(cursor='watch')
self.watershed_window.config(cursor='watch')
else: # is 'on'
idx = 0
for _name, _w in self.slider.items():
_w.configure(state=tk.NORMAL)
# Restore the slider values to overwrite any changes.
if self.slider_values and isinstance(_w, tk.Scale):
self.slider_val[_name].set(self.slider_values[idx])
idx += 1
for _, _w in self.cbox.items():
if isinstance(_w, tk.Label):
_w.configure(state=tk.NORMAL)
else: # is tk.Combobox
_w.configure(state='readonly')
for _, _w in self.button.items():
_w.grid()
for _, _w in self.size_std.items():
if not isinstance(_w, tk.StringVar):
_w.configure(state=tk.NORMAL)
# Need to keep the noise reduction widgets disabled when
# iterations are zero.
self.noise_widget_control()
self.config(cursor='')
self.watershed_window.config(cursor='')
self.slider_values.clear()
# Use update(), not update_idletasks, here to improve promptness
# of windows' response.
self.update()
def noise_widget_control(self) -> None:
"""
Disables noise reduction settings widgets when iterations are
zero and enable when not. Used to refine the broad actions of
widget_control(action='on').
Called by widget_control(), process_matte(), _Command.process().
Calls widget_control().
Returns: None
"""
if self.slider_val['noise_iter'].get() == 0:
for _name, _widget in self.cbox.items():
if 'morph' in _name:
_widget.configure(state=tk.DISABLED)
for _name, _widget in self.slider.items():
if 'noise_k' in _name:
_widget.configure(state=tk.DISABLED)
else: # is > 0, so widget now relevant.
# Need to re-enable the noise reduction widgets, but is simplest
# to re-enable all widgets.
for _, _widget in self.cbox.items():
if isinstance(_widget, tk.Label):
_widget.configure(state=tk.NORMAL)
else: # is tk.Combobox
_widget.configure(state='readonly')
for _, _widget in self.slider.items():
_widget.configure(state=tk.NORMAL)
def validate_px_size_entry(self) -> None:
"""
Check whether pixel size Entry() value is a positive integer.
Post a message if the entry is not valid.
Called by set_size_standard() process_ws(), process_matte().
Calls widget_control().
Returns: None
"""
size_std_px: str = self.size_std['px_val'].get()
try:
size_std_px_int = int(size_std_px)
if size_std_px_int <= 0:
raise ValueError
except ValueError:
# Need widget_control to prevent runaway sliders, if clicked.
self.widget_control(action='off')
_post = ('Enter a whole number > 0 for the pixel diameter.\n'
f'{size_std_px} was entered. Defaulting to 1.')
messagebox.showerror(title='Invalid entry',
detail=_post)
self.size_std['px_val'].set('1')
self.widget_control(action='on')
def validate_custom_size_entry(self) -> None:
"""
Check whether custom size Entry() value is a real number.
Post a message if the entry is not valid.
Called by set_size_standard() process_ws(), process_matte().
Calls widget_control().
Returns: None
"""
custom_size: str = self.size_std['custom_val'].get()
size_std_px: str = self.size_std['px_val'].get()
# Verify that entries are positive numbers. Define self.num_sigfig.
# Custom sizes can be entered as integer, float, or power operator.
# Number of significant figures is the lowest of that for the
# standard's size value or pixel diameter. Therefore, lo-res input
# are more likely to have size std diameters of <100 px, thus
# limiting calculated sizes to 2 sigfig.
try:
# float() will raise ValueError if custom_size is not a number.
custom_size_float = float(custom_size)
if custom_size_float <= 0:
raise ValueError
self.unit_per_px.set(custom_size_float / int(size_std_px))
if size_std_px == '1':
self.num_sigfig = utils.count_sig_fig(custom_size)
else:
self.num_sigfig = min(utils.count_sig_fig(custom_size),
utils.count_sig_fig(size_std_px))
except ValueError:
# Need widget_control() to prevent runaway sliders, if clicked.
self.widget_control(action='off')
messagebox.showinfo(title='Custom size',
detail='Enter a number > 0.\n'
'Accepted types:\n'
' integer: 26, 2651, 2_651\n'
' decimal: 26.5, 0.265, .2\n'
' exponent: 2.6e10, 2.6e-2')
self.size_std['custom_val'].set('0.0')
self.widget_control(action='on')
def set_size_standard(self) -> None:
"""
Assign a unit conversion factor to the observed pixel diameter
of the chosen size standard and calculate the number of
significant figures for preset or custom size entries.
Called from start_now(), process_sizes().
Calls validate_px_size_entry(), validate_custom_size_entry(),
and utils.count_sig_fig().
Returns: None
"""
self.validate_px_size_entry()
size_std_px: str = self.size_std['px_val'].get()
size_std: str = self.cbox_val['size_std'].get()
preset_std_size: float = const.SIZE_STANDARDS[size_std]
# For clarity, need to show the custom size Entry widget only
# when 'Custom' is selected.
# Verify that entries are numbers and define self.num_sigfig.
# Custom sizes can be entered as integer, float, or power operator.
# Number of significant figures is the lowest of that for the
# standard's size value or pixel diameter.
if size_std == 'Custom':
self.size_std['custom_entry'].grid()
self.size_std['custom_lbl'].grid()
self.validate_custom_size_entry()
else: # is one of the preset size standards or 'None'.
self.size_std['custom_entry'].grid_remove()
self.size_std['custom_lbl'].grid_remove()
self.size_std['custom_val'].set('0.0')
self.unit_per_px.set(preset_std_size / int(size_std_px))
if size_std_px == '1':
self.num_sigfig = utils.count_sig_fig(preset_std_size)
else:
self.num_sigfig = min(utils.count_sig_fig(preset_std_size),
utils.count_sig_fig(size_std_px))
def is_selected_contour(self, contour: np.ndarray) -> bool:
"""
Filter each contour segment based on size and position.
Exclude None elements, contours not in the specified size range,
and contours that have a coordinate point intersecting an image border.
Args:
contour: A numpy array of contour points.
Returns:
True if the contour is within the specified parameters,
False if not.
"""
# Filter each contour segment based on size and position.
# Exclude None elements.
# Exclude contours not in the specified size range.
# Exclude contours that have a coordinate point intersecting an img edge.
# ... those that touch top or left edge or are background.
# ... those that touch bottom or right edge.
# The size range slider values are radii pixels. This is done b/c:
# 1) Displayed values have fewer digits, so a cleaner slide bar.
# 2) Sizes are diameters, so radii are conceptually easier than areas.
# So, need to convert to area for the cv2.contourArea function.
c_area_min: float = self.slider_val['circle_r_min'].get() ** 2 * np.pi
c_area_max: float = self.slider_val['circle_r_max'].get() ** 2 * np.pi
# Set limits for coordinate points to identify contours that
# are within 1 px of an image file border (edge).
bottom_edge: int = self.input_ht - 1
right_edge: int = self.input_w - 1
if contour is None:
return False
if not c_area_max > cv2.contourArea(contour) >= c_area_min:
return False
if {0, 1}.intersection(set(contour.ravel())):
return False
for xy_point in contour:
if xy_point[0][0] == right_edge or xy_point[0][1] == bottom_edge:
return False
return True
def measure_object(self, contour: np.ndarray) -> tuple:
"""
Measure object's size as an enclosing circle diameter of its contour.
Calls to_p.to_precision() to set the number of significant figures.
Args: contour: A numpy array of contour points.
Returns: tuple of the circle's center coordinates, radius, and
size diameter to display.
"""
# Measure object's size as an enclosing circle diameter of its contour.
((x, y), radius) = cv2.minEnclosingCircle(contour)
# Note: sizes are full-length floats.
object_size: float = radius * 2 * self.unit_per_px.get()
# Need to set sig. fig. to display sizes in annotated image.
# num_sigfig value is determined in set_size_standard().
display_size: str = to_p.to_precision(value=object_size,
precision=self.num_sigfig)
# Need to have pixel diameters as integers. Because...
# When num_sigfig is 4, as is case for None:'1.001' in
# const.SIZE_STANDARDS, then for px_val==1, with lower
# sig.fig., objects <1000 px diameter would display as
# decimal fractions. So, round()...
if (self.size_std['px_val'].get() == '1' and
self.cbox_val['size_std'].get() == 'None'):
display_size = str(round(float(display_size)))
return x, y, radius, display_size
def annotate_object(self, x_coord: float,
y_coord: float,
radius: float,
size: str) -> None:
"""
Draw a circle around the object's coordinates and annotate
its size.
Args: x_coord, y_coord, radius, size: The object's center x and y,
radius, and size diameter to display.
Returns: None
"""
color: tuple = const.COLORS_CV[self.cbox_val['annotation_color'].get()]
# Need to properly center text in the circled object.
((txt_width, _), baseline) = cv2.getTextSize(
text=size,
fontFace=const.FONT_TYPE,
fontScale=self.font_scale,
thickness=self.line_thickness)
offset_x = txt_width / 2
cv2.circle(img=self.cvimg['sized'],
center=(round(x_coord),
round(y_coord)),
radius=round(radius),
color=color,
thickness=self.line_thickness,
lineType=cv2.LINE_AA,
)
cv2.putText(img=self.cvimg['sized'],
text=size,
org=(round(x_coord - offset_x),
round(y_coord + baseline)),
fontFace=const.FONT_TYPE,
fontScale=self.font_scale,
color=color,
thickness=self.line_thickness,
lineType=cv2.LINE_AA,
)
def select_and_size_objects(self) -> None:
"""
Select object contour ROI based on area size and position,
draw an enclosing circle around contours, then display the
diameter size over the input image. Objects are expected to be
oblong so that circle diameter can represent the object's length.
Called by process*() methods and annotation methods in call_cmd().
Calls is_selected_object(), measure_object(), annotate_object(),
to_p.to_precision(), utils.no_objects_found_msg() as needed,
and update_image().
Returns: None
"""
# Sized image copy is used in annotate_object().
self.cvimg['sized'] = self.cvimg['input'].copy()
# Need to determine whether the watershed algorithm is in use,
# which is the case when the ws control window is visible.
if self.watershed_window.wm_state() in 'normal, zoomed':
contour_pointset = self.watershed_basins
else: # is 'withdrawn' or 'iconic'
contour_pointset = self.matte_contours
# Note that with matte screens, the contour_pointset may contain
# a single element of the entire image, if no objects are found.
if contour_pointset is None or len(contour_pointset) == 1:
self.update_image(image_name='sized')
utils.no_objects_found_msg(caller=PROGRAM_NAME)
return
# Need to reset selected_sizes list for each call.
self.selected_sizes.clear()
self.object_labels.clear()
# Need to reset the number of objects selected for each call.
self.num_objects_selected = 0
for contour in contour_pointset:
if self.is_selected_contour(contour=contour):
_x, _y, _r, size_to_display = self.measure_object(contour)
self.annotate_object(x_coord=_x,
y_coord=_y,
radius=_r,
size=size_to_display)
# Save each object_size measurement to the selected_sizes
# list for reporting.
# Convert size_to_display string to float, assuming that individual
# sizes listed in the report may be used in a spreadsheet
# or for other statistical analysis.
self.selected_sizes.append(float(size_to_display))