traitly 0.1.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.
- traitly/__init__.py +39 -0
- traitly/cli.py +380 -0
- traitly/fruit_phenotyping/__init__.py +20 -0
- traitly/fruit_phenotyping/analysis_parameters.py +192 -0
- traitly/fruit_phenotyping/color_analysis.py +1412 -0
- traitly/fruit_phenotyping/color_plot.py +1136 -0
- traitly/fruit_phenotyping/external_analysis.py +1280 -0
- traitly/fruit_phenotyping/fruit_config.py +1313 -0
- traitly/fruit_phenotyping/geometry.py +419 -0
- traitly/fruit_phenotyping/internal_analysis.py +3173 -0
- traitly/fruit_phenotyping/mask.py +1654 -0
- traitly/fruit_phenotyping/processing.py +724 -0
- traitly/fruit_phenotyping/results_image.py +466 -0
- traitly/fruit_phenotyping/symmetry.py +223 -0
- traitly/package_data/config/setup-torch.py +30 -0
- traitly/package_data/models/label.pt +0 -0
- traitly/package_data/models/size_reference.pt +0 -0
- traitly/shiny_app/app.py +4094 -0
- traitly/shiny_app/www/parameters.json +100 -0
- traitly/utils/__init__.py +5 -0
- traitly/utils/basic_functions.py +359 -0
- traitly/utils/calibration.py +1085 -0
- traitly/utils/constants.py +20 -0
- traitly/utils/convert_pdf.py +305 -0
- traitly/utils/label.py +468 -0
- traitly-0.1.0.dist-info/METADATA +216 -0
- traitly-0.1.0.dist-info/RECORD +30 -0
- traitly-0.1.0.dist-info/WHEEL +4 -0
- traitly-0.1.0.dist-info/entry_points.txt +3 -0
- traitly-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,1412 @@
|
|
|
1
|
+
# traitly/fruit_phenotyping/color_analysis.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Color analysis tools for fruit phenotyping pipelines.
|
|
5
|
+
|
|
6
|
+
Provides functions to extract, normalize, and analyze color features
|
|
7
|
+
from fruit images across multiple color spaces (RGB, Lab, HSV, Grayscale).
|
|
8
|
+
Supports per-tissue analysis (total pericarp, outer pericarp, internal
|
|
9
|
+
pericarp, and locules), pixel-level histograms, and hue-based color
|
|
10
|
+
classification.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# ============================================================================
|
|
14
|
+
# STANDARD LIBRARY
|
|
15
|
+
# ============================================================================
|
|
16
|
+
from typing import List, Dict, Tuple, Optional
|
|
17
|
+
|
|
18
|
+
# ============================================================================
|
|
19
|
+
# THIRD-PARTY LIBRARIES
|
|
20
|
+
# ============================================================================
|
|
21
|
+
import cv2
|
|
22
|
+
import numpy as np
|
|
23
|
+
from scipy.stats import circmean, circstd
|
|
24
|
+
import matplotlib.pyplot as plt
|
|
25
|
+
from matplotlib.patches import Patch
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
# ============================================================================
|
|
29
|
+
# INTERNAL IMPORTS
|
|
30
|
+
# ============================================================================
|
|
31
|
+
from traitly.fruit_phenotyping.processing import get_internal_pericarp_contour
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
###########################################
|
|
35
|
+
# Normalization functions for lab and hsv #
|
|
36
|
+
###########################################
|
|
37
|
+
|
|
38
|
+
def normalize_lab_values(l_values: np.ndarray,
|
|
39
|
+
a_values: np.ndarray,
|
|
40
|
+
b_values: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
41
|
+
"""
|
|
42
|
+
Normalize raw OpenCV Lab channel values to standard ranges.
|
|
43
|
+
|
|
44
|
+
Rescales L to [0, 100] and a, b channels to [-128, 127].
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
l_values : np.ndarray
|
|
49
|
+
Raw L channel values in the OpenCV range [0, 255].
|
|
50
|
+
a_values : np.ndarray
|
|
51
|
+
Raw a channel values in the OpenCV range [0, 255].
|
|
52
|
+
b_values : np.ndarray
|
|
53
|
+
Raw b channel values in the OpenCV range [0, 255].
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
Tuple[np.ndarray, np.ndarray, np.ndarray]
|
|
58
|
+
Normalized (L, a, b) arrays as float32.
|
|
59
|
+
"""
|
|
60
|
+
l_values = l_values.astype(np.float32)
|
|
61
|
+
a_values = a_values.astype(np.float32)
|
|
62
|
+
b_values = b_values.astype(np.float32)
|
|
63
|
+
|
|
64
|
+
# Rescale L to 0-100, a and b to -128 to 127
|
|
65
|
+
l_normalized = (l_values * 100.0) / 255.0
|
|
66
|
+
a_normalized = a_values - 128.0
|
|
67
|
+
b_normalized = b_values - 128.0
|
|
68
|
+
|
|
69
|
+
return l_normalized, a_normalized, b_normalized
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def normalize_hsv_values(h_values: np.ndarray,
|
|
73
|
+
s_values: np.ndarray,
|
|
74
|
+
v_values: np.ndarray
|
|
75
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
76
|
+
"""
|
|
77
|
+
Normalize raw OpenCV HSV channel values to standard ranges.
|
|
78
|
+
|
|
79
|
+
Converts H from [0, 180] to [0, 360] degrees, and S, V from
|
|
80
|
+
[0, 255] to [0, 100].
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
h_values : np.ndarray
|
|
85
|
+
Raw hue values in the OpenCV range [0, 180].
|
|
86
|
+
s_values : np.ndarray
|
|
87
|
+
Raw saturation values in the OpenCV range [0, 255].
|
|
88
|
+
v_values : np.ndarray
|
|
89
|
+
Raw value/brightness values in the OpenCV range [0, 255].
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
Tuple[np.ndarray, np.ndarray, np.ndarray]
|
|
94
|
+
Normalized (H, S, V) arrays as float32.
|
|
95
|
+
"""
|
|
96
|
+
h_values = h_values.astype(np.float32)
|
|
97
|
+
s_values = s_values.astype(np.float32)
|
|
98
|
+
v_values = v_values.astype(np.float32)
|
|
99
|
+
|
|
100
|
+
# Convert H from 0-180 to 0-360
|
|
101
|
+
h_normalized = h_values * 2.0
|
|
102
|
+
# Convert S and V to 0-100 range
|
|
103
|
+
s_normalized = (s_values / 255.0) * 100.0
|
|
104
|
+
v_normalized = (v_values / 255.0) * 100.0
|
|
105
|
+
|
|
106
|
+
return h_normalized, s_normalized, v_normalized
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
###################################################
|
|
110
|
+
# Calculate circular mean and std for hue values #
|
|
111
|
+
##################################################
|
|
112
|
+
|
|
113
|
+
def circular_mean_and_std_hue(hue_values: np.ndarray,
|
|
114
|
+
hue_degree_values: Optional[np.ndarray] = None
|
|
115
|
+
) -> tuple[float, float]:
|
|
116
|
+
"""
|
|
117
|
+
Calculate the circular mean and standard deviation of hue values.
|
|
118
|
+
|
|
119
|
+
Uses circular statistics to correctly handle the angular wrap-around
|
|
120
|
+
of hue (e.g., values near 0° and 360° are treated as close).
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
hue_values : np.ndarray
|
|
125
|
+
Raw hue values in the OpenCV range [0, 180]. Used when
|
|
126
|
+
``hue_degree_values`` is not provided.
|
|
127
|
+
hue_degree_values : np.ndarray, optional
|
|
128
|
+
Hue values already converted to degrees [0, 360]. If provided,
|
|
129
|
+
``hue_values`` is ignored.
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
-------
|
|
133
|
+
tuple[float, float]
|
|
134
|
+
Circular mean and circular standard deviation in degrees.
|
|
135
|
+
Returns ``(nan, nan)`` if input is empty or None.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
if hue_values is None or len(hue_values) == 0:
|
|
139
|
+
return np.nan, np.nan
|
|
140
|
+
# Ensure we are always working with [0, 360] degrees
|
|
141
|
+
hue_deg = hue_degree_values.astype(np.float32) if hue_degree_values is not None \
|
|
142
|
+
else hue_values.astype(np.float32) * 2.0
|
|
143
|
+
|
|
144
|
+
mean_deg = circmean(hue_deg, high=360.0, low=0.0)
|
|
145
|
+
std_deg = circstd(hue_deg, high=360.0, low=0.0)
|
|
146
|
+
|
|
147
|
+
return float(mean_deg), float(std_deg)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
####################################
|
|
151
|
+
# Extract color and get statistics #
|
|
152
|
+
####################################
|
|
153
|
+
|
|
154
|
+
def extract_color_features(img: np.ndarray,
|
|
155
|
+
mask: np.ndarray,
|
|
156
|
+
stat: str = 'mean',
|
|
157
|
+
color_space: str = 'all',
|
|
158
|
+
dark_thresh: int = 15
|
|
159
|
+
) -> Dict[str, float]:
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
Extract summary color statistics from masked pixels of an image.
|
|
163
|
+
|
|
164
|
+
Calculates mean or median and standard deviation for each channel
|
|
165
|
+
across the valid (non-dark, non-background) pixels within the mask
|
|
166
|
+
region. Supports RGB, Lab, HSV, and Grayscale color spaces. For HSV,
|
|
167
|
+
hue is summarized using circular statistics.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
img : np.ndarray
|
|
172
|
+
Input image in BGR format.
|
|
173
|
+
mask : np.ndarray
|
|
174
|
+
Binary mask where 255 indicates pixels to include.
|
|
175
|
+
stat : str, optional
|
|
176
|
+
Summary statistic to compute. Either ``'mean'`` or ``'median'``.
|
|
177
|
+
Default is ``'mean'``.
|
|
178
|
+
color_space : str, optional
|
|
179
|
+
Color spaces to compute. Either ``'all'`` or a comma-separated
|
|
180
|
+
subset of ``'rgb'``, ``'lab'``, ``'hsv'``, ``'gray'``.
|
|
181
|
+
Default is ``'all'``.
|
|
182
|
+
|
|
183
|
+
Returns
|
|
184
|
+
-------
|
|
185
|
+
Dict[str, float]
|
|
186
|
+
Dictionary of color features keyed by channel name and statistic
|
|
187
|
+
suffix (e.g., ``'R_mean'``, ``'R_std'``, ``'L_mean'``, ``'H_mean'``).
|
|
188
|
+
Returns NaN values if no valid pixels are found.
|
|
189
|
+
|
|
190
|
+
Raises
|
|
191
|
+
------
|
|
192
|
+
ValueError
|
|
193
|
+
If ``color_space`` contains invalid entries or ``stat`` is not
|
|
194
|
+
``'mean'`` or ``'median'``.
|
|
195
|
+
"""
|
|
196
|
+
stat = stat.lower().strip()
|
|
197
|
+
color_space = color_space.lower().replace(' ', '')
|
|
198
|
+
|
|
199
|
+
if color_space == 'all':
|
|
200
|
+
spaces_to_compute = {'rgb', 'lab', 'hsv', 'gray'}
|
|
201
|
+
else:
|
|
202
|
+
spaces_to_compute = set(color_space.split(','))
|
|
203
|
+
|
|
204
|
+
valid_spaces = {'rgb', 'lab', 'hsv', 'gray'}
|
|
205
|
+
if not spaces_to_compute.issubset(valid_spaces):
|
|
206
|
+
invalid = spaces_to_compute - valid_spaces
|
|
207
|
+
raise ValueError(f"Invalid color spaces: {invalid}. Valid options: {valid_spaces}")
|
|
208
|
+
|
|
209
|
+
if stat not in {'mean', 'median'}:
|
|
210
|
+
raise ValueError("stat must be 'mean' or 'median'")
|
|
211
|
+
|
|
212
|
+
suffix = 'mean' if stat == 'mean' else 'median'
|
|
213
|
+
|
|
214
|
+
dark_threshold = dark_thresh
|
|
215
|
+
gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
216
|
+
valid_mask = (mask == 255) & (gray_img > dark_threshold)
|
|
217
|
+
|
|
218
|
+
if not np.any(valid_mask):
|
|
219
|
+
return _get_nan_color_dict(suffix)
|
|
220
|
+
|
|
221
|
+
def _agg_1d(v: np.ndarray) -> float:
|
|
222
|
+
return float(np.mean(v)) if stat == 'mean' else float(np.median(v))
|
|
223
|
+
|
|
224
|
+
def _agg_2d(v: np.ndarray) -> np.ndarray:
|
|
225
|
+
return np.mean(v, axis=0) if stat == 'mean' else np.median(v, axis=0)
|
|
226
|
+
|
|
227
|
+
out: Dict[str, float] = {}
|
|
228
|
+
|
|
229
|
+
if 'rgb' in spaces_to_compute:
|
|
230
|
+
bgr_pixels = img[valid_mask].astype(np.float32) # Nx3
|
|
231
|
+
rgb_pixels = bgr_pixels[:, ::-1]
|
|
232
|
+
rgb_stat = _agg_2d(rgb_pixels)
|
|
233
|
+
out[f'R_{suffix}'] = float(rgb_stat[0])
|
|
234
|
+
out[f'G_{suffix}'] = float(rgb_stat[1])
|
|
235
|
+
out[f'B_{suffix}'] = float(rgb_stat[2])
|
|
236
|
+
rgb_std = np.std(rgb_pixels, axis=0)
|
|
237
|
+
out['R_std'] = float(rgb_std[0])
|
|
238
|
+
out['G_std'] = float(rgb_std[1])
|
|
239
|
+
out['B_std'] = float(rgb_std[2])
|
|
240
|
+
|
|
241
|
+
if 'gray' in spaces_to_compute:
|
|
242
|
+
gray_pixels = gray_img[valid_mask].astype(np.float32)
|
|
243
|
+
out[f'Gray_{suffix}'] = _agg_1d(gray_pixels)
|
|
244
|
+
out['Gray_std'] = float(np.std(gray_pixels))
|
|
245
|
+
|
|
246
|
+
# Lab normalized
|
|
247
|
+
if 'lab' in spaces_to_compute:
|
|
248
|
+
lab_img = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
|
|
249
|
+
l_vals = lab_img[:, :, 0][valid_mask]
|
|
250
|
+
a_vals = lab_img[:, :, 1][valid_mask]
|
|
251
|
+
b_vals = lab_img[:, :, 2][valid_mask]
|
|
252
|
+
|
|
253
|
+
l_norm, a_norm, b_norm = normalize_lab_values(l_vals, a_vals, b_vals)
|
|
254
|
+
|
|
255
|
+
out[f'L_{suffix}'] = _agg_1d(l_norm)
|
|
256
|
+
out[f'a_{suffix}'] = _agg_1d(a_norm)
|
|
257
|
+
out[f'b_{suffix}'] = _agg_1d(b_norm)
|
|
258
|
+
out['L_std'] = float(np.std(l_norm))
|
|
259
|
+
out['a_std'] = float(np.std(a_norm))
|
|
260
|
+
out['b_std'] = float(np.std(b_norm))
|
|
261
|
+
|
|
262
|
+
# Hue in degrees
|
|
263
|
+
if 'hsv' in spaces_to_compute:
|
|
264
|
+
hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
|
265
|
+
h_vals = hsv_img[:, :, 0][valid_mask].astype(np.float32)
|
|
266
|
+
s_vals = hsv_img[:, :, 1][valid_mask].astype(np.float32)
|
|
267
|
+
v_vals = hsv_img[:, :, 2][valid_mask].astype(np.float32)
|
|
268
|
+
|
|
269
|
+
s_norm = (s_vals / 255.0) * 100.0
|
|
270
|
+
v_norm = (v_vals / 255.0) * 100.0
|
|
271
|
+
|
|
272
|
+
# Circular mean and std for hue
|
|
273
|
+
h_cmean, h_cstd = circular_mean_and_std_hue(hue_values=h_vals)
|
|
274
|
+
out[f'H_{suffix}'] = float(h_cmean)
|
|
275
|
+
out[f'S_{suffix}'] = _agg_1d(s_norm)
|
|
276
|
+
out[f'V_{suffix}'] = _agg_1d(v_norm)
|
|
277
|
+
out['H_std'] = float(h_cstd)
|
|
278
|
+
out['S_std'] = float(np.std(s_norm))
|
|
279
|
+
out['V_std'] = float(np.std(v_norm))
|
|
280
|
+
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
#####################################################################
|
|
285
|
+
# Extract color for whole fruit, outer pericarp, and inner pericarp #
|
|
286
|
+
#####################################################################
|
|
287
|
+
|
|
288
|
+
# Helper function to return NaN dictionary
|
|
289
|
+
def _get_nan_color_dict(stat_suffix: str = "mean"
|
|
290
|
+
) -> Dict[str, float]:
|
|
291
|
+
"""
|
|
292
|
+
Return a color feature dictionary with NaN values.
|
|
293
|
+
|
|
294
|
+
Used as a fallback when a mask contains no valid pixels.
|
|
295
|
+
|
|
296
|
+
Parameters
|
|
297
|
+
----------
|
|
298
|
+
stat_suffix : str, optional
|
|
299
|
+
Suffix used for column naming, either ``'mean'`` or ``'median'``.
|
|
300
|
+
Default is ``'mean'``.
|
|
301
|
+
|
|
302
|
+
Returns
|
|
303
|
+
-------
|
|
304
|
+
Dict[str, float]
|
|
305
|
+
Dictionary with NaN values for all color channels across RGB,
|
|
306
|
+
Lab, HSV, and Grayscale color spaces.
|
|
307
|
+
|
|
308
|
+
"""
|
|
309
|
+
return {
|
|
310
|
+
# Pixel stats
|
|
311
|
+
f'R_{stat_suffix}': np.nan, f'G_{stat_suffix}': np.nan, f'B_{stat_suffix}': np.nan,
|
|
312
|
+
f'L_{stat_suffix}': np.nan, f'a_{stat_suffix}': np.nan, f'b_{stat_suffix}': np.nan,
|
|
313
|
+
f'H_{stat_suffix}': np.nan, f'S_{stat_suffix}': np.nan, f'V_{stat_suffix}': np.nan,
|
|
314
|
+
f'Gray_{stat_suffix}': np.nan,
|
|
315
|
+
|
|
316
|
+
# Standard deviation:
|
|
317
|
+
'R_std': np.nan, 'G_std': np.nan, 'B_std': np.nan,
|
|
318
|
+
'L_std': np.nan, 'a_std': np.nan, 'b_std': np.nan,
|
|
319
|
+
'H_std': np.nan, 'S_std': np.nan, 'V_std': np.nan,
|
|
320
|
+
'Gray_std': np.nan
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
#########################################
|
|
325
|
+
# Helper function to renumber fruit IDs #
|
|
326
|
+
#########################################
|
|
327
|
+
|
|
328
|
+
def renumber_fruit_locule_map(fruit_locule_map: Dict[int, List[int]]
|
|
329
|
+
) -> Tuple[Dict[int, List[int]], Dict[int, int]]:
|
|
330
|
+
"""
|
|
331
|
+
Renumber fruit IDs sequentially from 1 to n.
|
|
332
|
+
|
|
333
|
+
Parameters
|
|
334
|
+
----------
|
|
335
|
+
fruit_locule_map : Dict[int, List[int]]
|
|
336
|
+
Original mapping of fruit IDs to lists of locule contour indices.
|
|
337
|
+
|
|
338
|
+
Returns
|
|
339
|
+
-------
|
|
340
|
+
Tuple[Dict[int, List[int]], Dict[int, int]]
|
|
341
|
+
A tuple of:
|
|
342
|
+
- Renumbered fruit-locule map with sequential IDs starting at 1.
|
|
343
|
+
- Mapping from new sequential IDs to original fruit IDs.
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
# Get original fruit IDs sorted (optional, for consistency)
|
|
347
|
+
original_ids = sorted(fruit_locule_map.keys())
|
|
348
|
+
|
|
349
|
+
renumbered_map = {}
|
|
350
|
+
fruit_id_map = {}
|
|
351
|
+
|
|
352
|
+
for new_id, original_id in enumerate(original_ids, start=1):
|
|
353
|
+
renumbered_map[new_id] = fruit_locule_map[original_id]
|
|
354
|
+
fruit_id_map[new_id] = original_id
|
|
355
|
+
|
|
356
|
+
return renumbered_map, fruit_id_map
|
|
357
|
+
|
|
358
|
+
########################################################
|
|
359
|
+
# Analyze color for all fruits in the fruit_locule_map #
|
|
360
|
+
########################################################
|
|
361
|
+
|
|
362
|
+
def analyze_all_fruits_color(img: np.ndarray,
|
|
363
|
+
mask: np.ndarray,
|
|
364
|
+
contours: List[np.ndarray],
|
|
365
|
+
fruit_locule_map: Dict[int, List[int]],
|
|
366
|
+
stat: str = 'mean',
|
|
367
|
+
tissue: str = 'all',
|
|
368
|
+
renumber: bool = True,
|
|
369
|
+
color_space: str = 'all',
|
|
370
|
+
dilation_factor: Optional[float] = None,
|
|
371
|
+
dark_threshold: int = 15
|
|
372
|
+
) -> Dict[int, Dict[str, Dict[str, float]]]:
|
|
373
|
+
"""
|
|
374
|
+
Analyze color features for all fruits in the fruit-locule map.
|
|
375
|
+
|
|
376
|
+
Iterates over each fruit, crops it to its bounding box, builds
|
|
377
|
+
tissue-specific masks, and extracts color features per tissue.
|
|
378
|
+
|
|
379
|
+
Parameters
|
|
380
|
+
----------
|
|
381
|
+
img : np.ndarray
|
|
382
|
+
Input image in BGR format.
|
|
383
|
+
mask : np.ndarray
|
|
384
|
+
Binary segmentation mask where fruit pixels are > 0.
|
|
385
|
+
contours : List[np.ndarray]
|
|
386
|
+
List of all contours (fruits and locules).
|
|
387
|
+
fruit_locule_map : Dict[int, List[int]]
|
|
388
|
+
Mapping of fruit contour indices to their locule contour indices.
|
|
389
|
+
stat : str, optional
|
|
390
|
+
Summary statistic: ``'mean'`` or ``'median'``. Default is ``'mean'``.
|
|
391
|
+
tissue : str, optional
|
|
392
|
+
Tissues to analyze. Either ``'all'`` or a comma-separated subset of
|
|
393
|
+
``'total_pericarp'``, ``'outer_pericarp'``, ``'internal_pericarp'``,
|
|
394
|
+
``'locules'``. Default is ``'all'``.
|
|
395
|
+
renumber : bool, optional
|
|
396
|
+
If True, fruit IDs are renumbered from 1 to n. Default is True.
|
|
397
|
+
color_space : str, optional
|
|
398
|
+
Color spaces to compute. Either ``'all'`` or a comma-separated
|
|
399
|
+
subset of ``'rgb'``, ``'lab'``, ``'hsv'``, ``'gray'``.
|
|
400
|
+
Default is ``'all'``.
|
|
401
|
+
|
|
402
|
+
Returns
|
|
403
|
+
-------
|
|
404
|
+
Dict[int, Dict[str, Dict[str, float]]]
|
|
405
|
+
Nested dictionary keyed by fruit ID, then tissue name, then
|
|
406
|
+
color feature name.
|
|
407
|
+
|
|
408
|
+
Raises
|
|
409
|
+
------
|
|
410
|
+
ValueError
|
|
411
|
+
If ``contour_mode`` or ``tissue`` contains invalid entries.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
tissue = tissue.lower().replace(' ', '')
|
|
415
|
+
|
|
416
|
+
if tissue == 'all':
|
|
417
|
+
tissues_to_extract = {'total_pericarp', 'outer_pericarp', 'internal_pericarp', 'locules'}
|
|
418
|
+
else:
|
|
419
|
+
tissues_to_extract = set(tissue.split(','))
|
|
420
|
+
|
|
421
|
+
valid_tissues = {'total_pericarp', 'outer_pericarp', 'internal_pericarp', 'locules'}
|
|
422
|
+
if not tissues_to_extract.issubset(valid_tissues):
|
|
423
|
+
invalid = tissues_to_extract - valid_tissues
|
|
424
|
+
raise ValueError(f"Invalid tissue: {invalid}. Valid options: {valid_tissues}")
|
|
425
|
+
|
|
426
|
+
# Renumber fruit ids from 1 to n fruits
|
|
427
|
+
if renumber:
|
|
428
|
+
iter_map, fruit_id_map = renumber_fruit_locule_map(fruit_locule_map)
|
|
429
|
+
else:
|
|
430
|
+
iter_map = fruit_locule_map
|
|
431
|
+
fruit_id_map = {k: k for k in fruit_locule_map.keys()}
|
|
432
|
+
|
|
433
|
+
# Precalculate a 'nan' dictionary
|
|
434
|
+
nan_dict = _get_nan_color_dict('mean' if stat == 'mean' else 'median')
|
|
435
|
+
|
|
436
|
+
results = {}
|
|
437
|
+
|
|
438
|
+
# Then, for each fruit and its locules:
|
|
439
|
+
for fruit_id, locule_indices in iter_map.items():
|
|
440
|
+
fruit_results = {}
|
|
441
|
+
|
|
442
|
+
# Use the original fruit_id to extract it from contours
|
|
443
|
+
original_fruit_id = fruit_id_map[fruit_id]
|
|
444
|
+
|
|
445
|
+
# Get fruit contour
|
|
446
|
+
|
|
447
|
+
if original_fruit_id >= len(contours):
|
|
448
|
+
for t in tissues_to_extract:
|
|
449
|
+
fruit_results[t] = nan_dict.copy()
|
|
450
|
+
|
|
451
|
+
results[fruit_id] = fruit_results
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
fruit_contour = contours[original_fruit_id]
|
|
455
|
+
|
|
456
|
+
# Create a (rotated) bounding-box around the fruit and cut the original image and the binary mask using its ROI
|
|
457
|
+
x, y, w, h = cv2.boundingRect(fruit_contour)
|
|
458
|
+
|
|
459
|
+
roi_raw = img[y:y+h, x:x+w]
|
|
460
|
+
roi_mask = mask[y:y+h, x:x+w]
|
|
461
|
+
|
|
462
|
+
mask_pericarp = ((roi_mask > 0).astype(np.uint8)) * 255
|
|
463
|
+
|
|
464
|
+
mask_locules = None
|
|
465
|
+
|
|
466
|
+
# Check if locules are needed. If 'locules' is not in tissue but 'internal_pericarp' is, the locule mask is still required.
|
|
467
|
+
need_locules_mask = bool(locule_indices) and (
|
|
468
|
+
('locules' in tissues_to_extract) or
|
|
469
|
+
('internal_pericarp' in tissues_to_extract)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if need_locules_mask:
|
|
473
|
+
mask_locules = np.zeros((h, w), dtype=np.uint8)
|
|
474
|
+
for locule_idx in locule_indices:
|
|
475
|
+
loc_contour = contours[locule_idx].copy()
|
|
476
|
+
loc_contour[:, :, 0] -= x
|
|
477
|
+
loc_contour[:, :, 1] -= y
|
|
478
|
+
cv2.drawContours(mask_locules, [loc_contour], -1, 255, cv2.FILLED)
|
|
479
|
+
|
|
480
|
+
# Only save the results if 'locules' is required (e.g., tissue = 'all' or tissue = 'locules')
|
|
481
|
+
if 'locules' in tissues_to_extract:
|
|
482
|
+
fruit_results['locules'] = (
|
|
483
|
+
extract_color_features(roi_raw, mask_locules, stat, color_space, dark_thresh = dark_threshold)
|
|
484
|
+
if np.any(mask_locules) else nan_dict.copy()
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# Use the original mask (locules = 0, fruit(total_pericarp)= 255)
|
|
488
|
+
if 'total_pericarp' in tissues_to_extract:
|
|
489
|
+
fruit_results['total_pericarp'] = (
|
|
490
|
+
extract_color_features(roi_raw, mask_pericarp, stat, color_space, dark_thresh = dark_threshold)
|
|
491
|
+
if np.any(mask_pericarp) else nan_dict.copy()
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Verify that the fruit has valid locules, otherwise, assign NaN results
|
|
495
|
+
if not locule_indices:
|
|
496
|
+
for t in ('outer_pericarp', 'internal_pericarp'):
|
|
497
|
+
if t in tissues_to_extract:
|
|
498
|
+
fruit_results[t] = nan_dict.copy()
|
|
499
|
+
|
|
500
|
+
results[fruit_id] = fruit_results
|
|
501
|
+
continue
|
|
502
|
+
|
|
503
|
+
need_internal_contour = (
|
|
504
|
+
('internal_pericarp' in tissues_to_extract) or
|
|
505
|
+
('outer_pericarp' in tissues_to_extract)
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
if not need_internal_contour:
|
|
509
|
+
results[fruit_id] = fruit_results
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
internal_flesh_contour = get_internal_pericarp_contour(locule_indices,
|
|
513
|
+
contours,
|
|
514
|
+
dilation_factor = dilation_factor,
|
|
515
|
+
img_shape = img.shape[:2],
|
|
516
|
+
fruit_id = original_fruit_id)
|
|
517
|
+
|
|
518
|
+
if internal_flesh_contour is None or len(internal_flesh_contour) == 0:
|
|
519
|
+
for t in ('outer_pericarp', 'internal_pericarp'):
|
|
520
|
+
if t in tissues_to_extract:
|
|
521
|
+
fruit_results[t] = nan_dict.copy()
|
|
522
|
+
|
|
523
|
+
results[fruit_id] = fruit_results
|
|
524
|
+
continue
|
|
525
|
+
|
|
526
|
+
internal_flesh_contour_roi = internal_flesh_contour.copy()
|
|
527
|
+
internal_flesh_contour_roi[:, :, 0] -= x
|
|
528
|
+
internal_flesh_contour_roi[:, :, 1] -= y
|
|
529
|
+
|
|
530
|
+
mask_internal_area = np.zeros((h, w), dtype=np.uint8)
|
|
531
|
+
cv2.drawContours(mask_internal_area, [internal_flesh_contour_roi], -1, 255, cv2.FILLED)
|
|
532
|
+
|
|
533
|
+
if 'internal_pericarp' in tissues_to_extract:
|
|
534
|
+
mask_internal = mask_internal_area.copy()
|
|
535
|
+
if mask_locules is not None and np.any(mask_locules):
|
|
536
|
+
mask_internal[mask_locules == 255] = 0
|
|
537
|
+
fruit_results['internal_pericarp'] = (
|
|
538
|
+
extract_color_features(roi_raw, mask_internal, stat, color_space, dark_thresh = dark_threshold)
|
|
539
|
+
if np.any(mask_internal) else nan_dict.copy()
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
if 'outer_pericarp' in tissues_to_extract:
|
|
543
|
+
mask_outer = mask_pericarp.copy()
|
|
544
|
+
mask_outer[mask_internal_area == 255] = 0
|
|
545
|
+
fruit_results['outer_pericarp'] = (
|
|
546
|
+
extract_color_features(roi_raw, mask_outer, stat, color_space, dark_thresh = dark_threshold)
|
|
547
|
+
if np.any(mask_outer) else nan_dict.copy()
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
results[fruit_id] = fruit_results
|
|
551
|
+
|
|
552
|
+
return results
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
#############################################################
|
|
556
|
+
# Create masks for a single fruit and its different tissue #
|
|
557
|
+
#############################################################
|
|
558
|
+
|
|
559
|
+
def get_single_fruit_masks(img: np.ndarray,
|
|
560
|
+
mask: np.ndarray,
|
|
561
|
+
contours: List[np.ndarray],
|
|
562
|
+
fruit_locule_map: Dict[int, List[int]],
|
|
563
|
+
fruit_id: Optional[int] = None,
|
|
564
|
+
renumber: bool = True,
|
|
565
|
+
plot: bool = True,
|
|
566
|
+
plot_size: Tuple[int, int] = (7, 5),
|
|
567
|
+
overlay:bool = False,
|
|
568
|
+
margin: int = 5,
|
|
569
|
+
overlay_legend: bool = True,
|
|
570
|
+
only_fruit: bool = False,
|
|
571
|
+
dilation_factor: Optional[float] = None,
|
|
572
|
+
) -> Dict[str, np.ndarray]:
|
|
573
|
+
"""
|
|
574
|
+
Generate tissue masks for a single fruit cropped to its bounding box.
|
|
575
|
+
|
|
576
|
+
Builds binary masks for each tissue region of the selected fruit and
|
|
577
|
+
optionally visualizes them.
|
|
578
|
+
|
|
579
|
+
Parameters
|
|
580
|
+
----------
|
|
581
|
+
img : np.ndarray
|
|
582
|
+
Input image in BGR format.
|
|
583
|
+
mask : np.ndarray
|
|
584
|
+
Binary segmentation mask where fruit pixels are white [255].
|
|
585
|
+
contours : List[np.ndarray]
|
|
586
|
+
List of all contours (fruits and locules).
|
|
587
|
+
fruit_locule_map : Dict[int, List[int]]
|
|
588
|
+
Mapping of fruit contour indices to their locule contour indices.
|
|
589
|
+
fruit_id : int, optional
|
|
590
|
+
ID of the fruit to analyze. If None, the first fruit with locules
|
|
591
|
+
is selected automatically.
|
|
592
|
+
renumber : bool, optional
|
|
593
|
+
If True, fruit IDs are renumbered from 1 to n. Default is True.
|
|
594
|
+
plot : bool, optional
|
|
595
|
+
If True, display tissue masks. Default is True.
|
|
596
|
+
plot_size : Tuple[int, int], optional
|
|
597
|
+
Figure size for visualization. Default is (7, 5).
|
|
598
|
+
overlay : bool, optional
|
|
599
|
+
If True, display an overlay visualization instead of individual masks.
|
|
600
|
+
Default is False.
|
|
601
|
+
margin : int, optional
|
|
602
|
+
Pixel margin to add around the fruit bounding box. Default is 5.
|
|
603
|
+
overlay_legend : bool, optional
|
|
604
|
+
If True, include a legend in the overlay plot. Default is True.
|
|
605
|
+
only_fruit : bool, optional
|
|
606
|
+
If True, display only the total pericarp mask. Default is False.
|
|
607
|
+
|
|
608
|
+
Returns
|
|
609
|
+
-------
|
|
610
|
+
Dict[str, np.ndarray]
|
|
611
|
+
Dictionary containing masks and metadata with keys:
|
|
612
|
+
``'total_pericarp'``, ``'outer_pericarp'``, ``'internal_pericarp'``,
|
|
613
|
+
``'locules'``, ``'cropped_img'``, ``'bounding_box'``, ``'fruit_id'``.
|
|
614
|
+
|
|
615
|
+
Raises
|
|
616
|
+
------
|
|
617
|
+
ValueError
|
|
618
|
+
If ``fruit_locule_map`` is empty, ``fruit_id`` is out of range,
|
|
619
|
+
or no contour is found for the requested fruit.
|
|
620
|
+
TypeError
|
|
621
|
+
If ``fruit_id`` is not an integer.
|
|
622
|
+
"""
|
|
623
|
+
|
|
624
|
+
if not fruit_locule_map:
|
|
625
|
+
raise ValueError("No fruits found in the fruit_locule_map. Run detect_fruits() first.")
|
|
626
|
+
|
|
627
|
+
if renumber:
|
|
628
|
+
iter_map, fruit_id_map = renumber_fruit_locule_map(fruit_locule_map)
|
|
629
|
+
else:
|
|
630
|
+
iter_map = fruit_locule_map
|
|
631
|
+
fruit_id_map = {k: k for k in fruit_locule_map.keys()}
|
|
632
|
+
|
|
633
|
+
# Validate fruit_id
|
|
634
|
+
if fruit_id is not None:
|
|
635
|
+
if not isinstance(fruit_id, int):
|
|
636
|
+
raise TypeError(f"fruit_id: {fruit_id} must be an integer.")
|
|
637
|
+
if fruit_id < 1 or fruit_id > len(iter_map):
|
|
638
|
+
raise ValueError(f"fruit_id: {fruit_id} should be between 1 and {len(iter_map)}")
|
|
639
|
+
|
|
640
|
+
# If not provided, pick first fruit with locules; else first fruit
|
|
641
|
+
if fruit_id is None:
|
|
642
|
+
for fid, locs in iter_map.items():
|
|
643
|
+
if locs:
|
|
644
|
+
fruit_id = fid
|
|
645
|
+
break
|
|
646
|
+
if fruit_id is None:
|
|
647
|
+
fruit_id = list(iter_map.keys())[0]
|
|
648
|
+
|
|
649
|
+
# Work with the original ids
|
|
650
|
+
original_fruit_id = fruit_id_map[fruit_id]
|
|
651
|
+
if original_fruit_id >= len(contours):
|
|
652
|
+
raise ValueError(f"Contour not found for fruit {original_fruit_id}")
|
|
653
|
+
|
|
654
|
+
fruit_contour = contours[original_fruit_id]
|
|
655
|
+
if fruit_contour is None or len(fruit_contour) == 0:
|
|
656
|
+
raise ValueError(f"Contour not found for fruit {original_fruit_id}")
|
|
657
|
+
|
|
658
|
+
# Bounding box + margin
|
|
659
|
+
x, y, w, h = cv2.boundingRect(fruit_contour)
|
|
660
|
+
m = margin if margin is not None else 0
|
|
661
|
+
|
|
662
|
+
x_start = max(0, x - m)
|
|
663
|
+
y_start = max(0, y - m)
|
|
664
|
+
x_end = min(img.shape[1], x + w + m)
|
|
665
|
+
y_end = min(img.shape[0], y + h + m)
|
|
666
|
+
|
|
667
|
+
# Crop original image and binary mask using the bounding box coords
|
|
668
|
+
cropped_img = img[y_start:y_end, x_start:x_end]
|
|
669
|
+
roi_mask = mask[y_start:y_end, x_start:x_end]
|
|
670
|
+
|
|
671
|
+
crop_h, crop_w = cropped_img.shape[:2]
|
|
672
|
+
|
|
673
|
+
# Total pericarp (fruit mask without locules)
|
|
674
|
+
total_pericarp = ((roi_mask > 0).astype(np.uint8)) * 255 # Just ensuring roi_mask is a binary mask
|
|
675
|
+
|
|
676
|
+
masks: Dict[str, np.ndarray] = {}
|
|
677
|
+
masks["total_pericarp"] = total_pericarp # Save the tp_mask
|
|
678
|
+
|
|
679
|
+
locule_indices = iter_map.get(fruit_id, [])
|
|
680
|
+
|
|
681
|
+
# No locules case
|
|
682
|
+
if not locule_indices:
|
|
683
|
+
masks["outer_pericarp"] = total_pericarp.copy()
|
|
684
|
+
masks["internal_pericarp"] = np.zeros((crop_h, crop_w), dtype=np.uint8)
|
|
685
|
+
masks["locules"] = np.zeros((crop_h, crop_w), dtype=np.uint8)
|
|
686
|
+
|
|
687
|
+
else:
|
|
688
|
+
inner_contour = get_internal_pericarp_contour(locule_indices,
|
|
689
|
+
contours,
|
|
690
|
+
fruit_id = original_fruit_id,
|
|
691
|
+
img_shape = img.shape[:2],
|
|
692
|
+
dilation_factor = dilation_factor
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
if inner_contour is None or len(inner_contour) == 0:
|
|
696
|
+
masks["outer_pericarp"] = total_pericarp.copy()
|
|
697
|
+
masks["internal_pericarp"] = np.zeros_like(total_pericarp)
|
|
698
|
+
masks["locules"] = np.zeros_like(total_pericarp)
|
|
699
|
+
|
|
700
|
+
else:
|
|
701
|
+
# Adjust internal contour to ROI
|
|
702
|
+
inner_contour_adj = inner_contour.copy()
|
|
703
|
+
inner_contour_adj[:, :, 0] -= x_start
|
|
704
|
+
inner_contour_adj[:, :, 1] -= y_start
|
|
705
|
+
|
|
706
|
+
inner_area = np.zeros((crop_h, crop_w), dtype=np.uint8)
|
|
707
|
+
cv2.drawContours(inner_area, [inner_contour_adj], -1, 255, cv2.FILLED)
|
|
708
|
+
|
|
709
|
+
# Locules mask
|
|
710
|
+
loc_mask = np.zeros((crop_h, crop_w), dtype=np.uint8)
|
|
711
|
+
for loc_idx in locule_indices:
|
|
712
|
+
if loc_idx >= len(contours):
|
|
713
|
+
continue
|
|
714
|
+
loc_contour = contours[loc_idx]
|
|
715
|
+
if loc_contour is None or len(loc_contour) == 0:
|
|
716
|
+
continue
|
|
717
|
+
loc_contour = loc_contour.copy()
|
|
718
|
+
loc_contour[:, :, 0] -= x_start
|
|
719
|
+
loc_contour[:, :, 1] -= y_start
|
|
720
|
+
cv2.drawContours(loc_mask, [loc_contour], -1, 255, cv2.FILLED)
|
|
721
|
+
|
|
722
|
+
masks["locules"] = loc_mask
|
|
723
|
+
|
|
724
|
+
# Internal pericarp = internal area - locules
|
|
725
|
+
masks["internal_pericarp"] = cv2.subtract(inner_area, loc_mask)
|
|
726
|
+
|
|
727
|
+
# Outer pericarp = total pericarp - internal area
|
|
728
|
+
masks["outer_pericarp"] = cv2.subtract(total_pericarp, inner_area)
|
|
729
|
+
|
|
730
|
+
# Remove locules from total pericarp
|
|
731
|
+
masks["total_pericarp"] = cv2.subtract(total_pericarp, loc_mask)
|
|
732
|
+
|
|
733
|
+
masks["cropped_img"] = cropped_img
|
|
734
|
+
|
|
735
|
+
# Save bounding_box and fruit_id info (metadata)
|
|
736
|
+
masks["bounding_box"] = (x_start, y_start, x_end - x_start, y_end - y_start)
|
|
737
|
+
masks["fruit_id"] = fruit_id
|
|
738
|
+
|
|
739
|
+
if plot:
|
|
740
|
+
if overlay:
|
|
741
|
+
visualize_single_fruit_overlay(masks=masks,
|
|
742
|
+
plot_size=plot_size,
|
|
743
|
+
overlay_legend = overlay_legend)
|
|
744
|
+
else:
|
|
745
|
+
visualize_single_fruit_masks(masks=masks,
|
|
746
|
+
plot_size=plot_size,
|
|
747
|
+
only_fruit = only_fruit)
|
|
748
|
+
|
|
749
|
+
return masks
|
|
750
|
+
|
|
751
|
+
#################################################
|
|
752
|
+
# Visualize different tissues of a single fruit #
|
|
753
|
+
#################################################
|
|
754
|
+
|
|
755
|
+
def visualize_single_fruit_masks(masks: Dict[str, np.ndarray],
|
|
756
|
+
plot_size: Tuple[int, int] = (12, 4),
|
|
757
|
+
only_fruit: Optional[bool] = None
|
|
758
|
+
):
|
|
759
|
+
"""
|
|
760
|
+
Display individual binary masks for each tissue of a single fruit.
|
|
761
|
+
|
|
762
|
+
Parameters
|
|
763
|
+
----------
|
|
764
|
+
masks : Dict[str, np.ndarray]
|
|
765
|
+
Dictionary of tissue masks as returned by :func:`get_single_fruit_masks`.
|
|
766
|
+
Expected keys: ``'total_pericarp'``, ``'outer_pericarp'``,
|
|
767
|
+
``'internal_pericarp'``, ``'locules'``, ``'cropped_img'``.
|
|
768
|
+
plot_size : Tuple[int, int], optional
|
|
769
|
+
Figure size for the matplotlib plot. Default is (12, 4).
|
|
770
|
+
only_fruit : bool, optional
|
|
771
|
+
If True, display only the total pericarp mask alongside the
|
|
772
|
+
original image. Default is None (show all tissues).
|
|
773
|
+
"""
|
|
774
|
+
|
|
775
|
+
if only_fruit:
|
|
776
|
+
tissue_display_names = {
|
|
777
|
+
'total_pericarp': 'Total pericarp'
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
display_order = ['total_pericarp']
|
|
781
|
+
|
|
782
|
+
valid_masks = [m for m in display_order if m in masks]
|
|
783
|
+
|
|
784
|
+
if not valid_masks:
|
|
785
|
+
print("There are no masks to display.")
|
|
786
|
+
return
|
|
787
|
+
|
|
788
|
+
n_masks = len(valid_masks)
|
|
789
|
+
|
|
790
|
+
fig, axes = plt.subplots(1, n_masks + 1, figsize=plot_size)
|
|
791
|
+
|
|
792
|
+
# Include a plot for the bgr image
|
|
793
|
+
if n_masks + 1 == 1:
|
|
794
|
+
axes = [axes]
|
|
795
|
+
|
|
796
|
+
if 'cropped_img' in masks:
|
|
797
|
+
fontsize = int(plot_size[0] * 1.3)
|
|
798
|
+
fontsize = max(0.5, min(40, fontsize))
|
|
799
|
+
|
|
800
|
+
cropped_img = masks['cropped_img']
|
|
801
|
+
if cropped_img.ndim == 3 and cropped_img.shape[2] == 3:
|
|
802
|
+
img_display = cv2.cvtColor(cropped_img, cv2.COLOR_BGR2RGB)
|
|
803
|
+
else:
|
|
804
|
+
img_display = cropped_img
|
|
805
|
+
|
|
806
|
+
axes[0].imshow(img_display)
|
|
807
|
+
axes[0].set_title('Original Fruit', fontweight='bold', fontsize=fontsize)
|
|
808
|
+
axes[0].axis('off')
|
|
809
|
+
|
|
810
|
+
for idx, mask_type in enumerate(valid_masks, 1):
|
|
811
|
+
mask = masks[mask_type]
|
|
812
|
+
axes[idx].imshow(mask, cmap='gray', interpolation = 'nearest')
|
|
813
|
+
display_name = tissue_display_names.get(mask_type, mask_type.replace('_', ' ').title())
|
|
814
|
+
axes[idx].set_title('Fruit Mask', fontweight='bold', fontsize=fontsize)
|
|
815
|
+
axes[idx].axis('off')
|
|
816
|
+
|
|
817
|
+
plt.tight_layout()
|
|
818
|
+
plt.show()
|
|
819
|
+
|
|
820
|
+
else:
|
|
821
|
+
# Tissue names for visualization
|
|
822
|
+
tissue_display_names = {
|
|
823
|
+
'total_pericarp': 'Total pericarp',
|
|
824
|
+
'outer_pericarp': 'Outer Pericarp',
|
|
825
|
+
'internal_pericarp': 'Internal pericarp',
|
|
826
|
+
'locules': 'Locules'
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
# Ensure plots are always shown in the same order
|
|
831
|
+
display_order = ['total_pericarp', 'outer_pericarp', 'internal_pericarp', 'locules']
|
|
832
|
+
|
|
833
|
+
valid_masks = [m for m in display_order if m in masks]
|
|
834
|
+
|
|
835
|
+
if not valid_masks:
|
|
836
|
+
print("There are no masks to display.")
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
n_masks = len(valid_masks)
|
|
840
|
+
|
|
841
|
+
fig, axes = plt.subplots(1, n_masks + 1, figsize=plot_size)
|
|
842
|
+
|
|
843
|
+
# Include a plot for the bgr image
|
|
844
|
+
if n_masks + 1 == 1:
|
|
845
|
+
axes = [axes]
|
|
846
|
+
|
|
847
|
+
if 'cropped_img' in masks:
|
|
848
|
+
fontsize = int(plot_size[0] * 1.3)
|
|
849
|
+
fontsize = max(0.5, min(40, fontsize))
|
|
850
|
+
|
|
851
|
+
cropped_img = masks['cropped_img']
|
|
852
|
+
if cropped_img.ndim == 3 and cropped_img.shape[2] == 3:
|
|
853
|
+
img_display = cv2.cvtColor(cropped_img, cv2.COLOR_BGR2RGB)
|
|
854
|
+
else:
|
|
855
|
+
img_display = cropped_img
|
|
856
|
+
|
|
857
|
+
axes[0].imshow(img_display)
|
|
858
|
+
axes[0].set_title('Original Fruit', fontweight='bold', fontsize=fontsize)
|
|
859
|
+
axes[0].axis('off')
|
|
860
|
+
|
|
861
|
+
for idx, mask_type in enumerate(valid_masks, 1):
|
|
862
|
+
mask = masks[mask_type]
|
|
863
|
+
axes[idx].imshow(mask, cmap='gray')
|
|
864
|
+
display_name = tissue_display_names.get(mask_type, mask_type.replace('_', ' ').title())
|
|
865
|
+
axes[idx].set_title(display_name, fontweight='bold', fontsize=fontsize)
|
|
866
|
+
axes[idx].axis('off')
|
|
867
|
+
|
|
868
|
+
plt.tight_layout()
|
|
869
|
+
plt.show()
|
|
870
|
+
|
|
871
|
+
###########################################################
|
|
872
|
+
# Visualize different tissues overlying on a single fruit #
|
|
873
|
+
###########################################################
|
|
874
|
+
|
|
875
|
+
def visualize_single_fruit_overlay(masks: Dict[str, np.ndarray],
|
|
876
|
+
plot_size: Tuple[int, int] = (12, 4),
|
|
877
|
+
overlay_legend: bool = True
|
|
878
|
+
):
|
|
879
|
+
"""
|
|
880
|
+
Display a color overlay of tissue masks on the cropped fruit image.
|
|
881
|
+
|
|
882
|
+
Applies semi-transparent color overlays for each tissue region
|
|
883
|
+
on top of the original fruit image using alpha blending.
|
|
884
|
+
|
|
885
|
+
Parameters
|
|
886
|
+
----------
|
|
887
|
+
masks : Dict[str, np.ndarray]
|
|
888
|
+
Dictionary of tissue masks as returned by :func:`get_single_fruit_masks`.
|
|
889
|
+
Must contain ``'cropped_img'``. Optional tissue keys:
|
|
890
|
+
``'outer_pericarp'``, ``'internal_pericarp'``, ``'locules'``.
|
|
891
|
+
plot_size : Tuple[int, int], optional
|
|
892
|
+
Figure size for the matplotlib plot. Default is (12, 4).
|
|
893
|
+
overlay_legend : bool, optional
|
|
894
|
+
If True, display a legend identifying tissue colors. Default is True.
|
|
895
|
+
"""
|
|
896
|
+
|
|
897
|
+
if "cropped_img" not in masks:
|
|
898
|
+
return
|
|
899
|
+
|
|
900
|
+
cropped_img = masks["cropped_img"].copy()
|
|
901
|
+
|
|
902
|
+
# Convert to RGB for plotting
|
|
903
|
+
if cropped_img.ndim == 3:
|
|
904
|
+
overlay_img = cv2.cvtColor(cropped_img, cv2.COLOR_BGR2RGB)
|
|
905
|
+
else:
|
|
906
|
+
overlay_img = cv2.cvtColor(cropped_img, cv2.COLOR_GRAY2RGB)
|
|
907
|
+
|
|
908
|
+
# Tissue colors (RGBA) where A is 0-255 alpha
|
|
909
|
+
tissue_colors = {
|
|
910
|
+
"outer_pericarp": (255, 200, 0, 100), # Yellow
|
|
911
|
+
"internal_pericarp": (255, 100, 100, 100), # Pink
|
|
912
|
+
"locules": (100, 200, 255, 150), # Blue
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
fig, axes = plt.subplots(1, 2, figsize=plot_size)
|
|
916
|
+
|
|
917
|
+
fontsize = int(plot_size[0] * 1.3)
|
|
918
|
+
|
|
919
|
+
axes[0].imshow(overlay_img)
|
|
920
|
+
axes[0].set_title("Original Fruit", fontweight="bold", fontsize=fontsize)
|
|
921
|
+
axes[0].axis("off")
|
|
922
|
+
|
|
923
|
+
# Create overlay image (float in [0,1])
|
|
924
|
+
overlay_display = overlay_img.astype(np.float32) / 255.0
|
|
925
|
+
|
|
926
|
+
# Apply colored overlays
|
|
927
|
+
for tissue, color in tissue_colors.items():
|
|
928
|
+
if tissue in masks and masks[tissue] is not None and np.any(masks[tissue] > 0):
|
|
929
|
+
mask = masks[tissue] > 0
|
|
930
|
+
if mask.any():
|
|
931
|
+
color_rgb = np.array(color[:3], dtype=np.float32) / 255.0
|
|
932
|
+
alpha = float(color[3]) / 255.0
|
|
933
|
+
|
|
934
|
+
# Alpha blend
|
|
935
|
+
for c in range(3):
|
|
936
|
+
overlay_display[:, :, c][mask] = (
|
|
937
|
+
overlay_display[:, :, c][mask] * (1.0 - alpha)
|
|
938
|
+
+ color_rgb[c] * alpha
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
axes[1].imshow(overlay_display)
|
|
942
|
+
axes[1].set_title("Tissue Overlay", fontweight="bold", fontsize=fontsize)
|
|
943
|
+
axes[1].axis("off")
|
|
944
|
+
|
|
945
|
+
# Legend if requested
|
|
946
|
+
if overlay_legend:
|
|
947
|
+
legend_elements = [
|
|
948
|
+
Patch(
|
|
949
|
+
facecolor=np.array(color[:3], dtype=np.float32) / 255.0,
|
|
950
|
+
alpha=float(color[3]) / 255.0,
|
|
951
|
+
label=tissue.replace("_", " ").title(),
|
|
952
|
+
)
|
|
953
|
+
for tissue, color in tissue_colors.items()
|
|
954
|
+
if tissue in masks and masks[tissue] is not None and np.any(masks[tissue] > 0)
|
|
955
|
+
]
|
|
956
|
+
|
|
957
|
+
if legend_elements:
|
|
958
|
+
axes[1].legend(handles=legend_elements, loc="upper right", fontsize=fontsize)
|
|
959
|
+
|
|
960
|
+
plt.tight_layout()
|
|
961
|
+
plt.show()
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
########################################
|
|
965
|
+
# Create id annotations for each fruit #
|
|
966
|
+
########################################
|
|
967
|
+
|
|
968
|
+
def annotate_fruits_on_image(
|
|
969
|
+
img: np.ndarray,
|
|
970
|
+
contours: List[np.ndarray],
|
|
971
|
+
fruit_locule_map: Dict[int, List[int]],
|
|
972
|
+
renumber: bool = True,
|
|
973
|
+
color: Tuple[int, int, int] = (0, 255, 0),
|
|
974
|
+
thickness: int = 2,
|
|
975
|
+
font_scale: float = 0.6
|
|
976
|
+
) -> np.ndarray:
|
|
977
|
+
"""
|
|
978
|
+
Annotate an image with fruit IDs and bounding boxes.
|
|
979
|
+
|
|
980
|
+
Parameters
|
|
981
|
+
----------
|
|
982
|
+
img : np.ndarray
|
|
983
|
+
Input image in BGR format.
|
|
984
|
+
contours : List[np.ndarray]
|
|
985
|
+
List of all contours (fruits and locules).
|
|
986
|
+
fruit_locule_map : Dict[int, List[int]]
|
|
987
|
+
Mapping of fruit contour indices to their locule contour indices.
|
|
988
|
+
renumber : bool, optional
|
|
989
|
+
If True, fruit IDs are renumbered from 1 to n. Default is True.
|
|
990
|
+
color : Tuple[int, int, int], optional
|
|
991
|
+
BGR color for bounding boxes and text. Default is green (0, 255, 0).
|
|
992
|
+
thickness : int, optional
|
|
993
|
+
Line thickness for bounding boxes. Default is 2.
|
|
994
|
+
font_scale : float, optional
|
|
995
|
+
Font scale for fruit ID labels. Default is 0.6.
|
|
996
|
+
|
|
997
|
+
Returns
|
|
998
|
+
-------
|
|
999
|
+
np.ndarray
|
|
1000
|
+
Annotated copy of the input image in BGR format.
|
|
1001
|
+
"""
|
|
1002
|
+
|
|
1003
|
+
annotated = img.copy()
|
|
1004
|
+
|
|
1005
|
+
if not fruit_locule_map:
|
|
1006
|
+
return annotated
|
|
1007
|
+
|
|
1008
|
+
# Handle renumbering
|
|
1009
|
+
if renumber:
|
|
1010
|
+
iter_map, fruit_id_map = renumber_fruit_locule_map(fruit_locule_map)
|
|
1011
|
+
else:
|
|
1012
|
+
iter_map = fruit_locule_map
|
|
1013
|
+
fruit_id_map = {k: k for k in fruit_locule_map.keys()}
|
|
1014
|
+
|
|
1015
|
+
for fruit_id, _ in iter_map.items():
|
|
1016
|
+
original_fruit_id = fruit_id_map[fruit_id]
|
|
1017
|
+
|
|
1018
|
+
if original_fruit_id >= len(contours):
|
|
1019
|
+
continue
|
|
1020
|
+
|
|
1021
|
+
contour = contours[original_fruit_id]
|
|
1022
|
+
if contour is None or len(contour) == 0:
|
|
1023
|
+
continue
|
|
1024
|
+
|
|
1025
|
+
x, y, w, h = cv2.boundingRect(contour)
|
|
1026
|
+
|
|
1027
|
+
# Draw bounding box
|
|
1028
|
+
cv2.rectangle(
|
|
1029
|
+
annotated,
|
|
1030
|
+
(x, y),
|
|
1031
|
+
(x + w, y + h),
|
|
1032
|
+
color,
|
|
1033
|
+
thickness
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
# Text position (slightly above the box)
|
|
1037
|
+
text = f"Fruit {fruit_id}"
|
|
1038
|
+
text_x = x
|
|
1039
|
+
text_y = max(0, y - 8)
|
|
1040
|
+
|
|
1041
|
+
cv2.putText(
|
|
1042
|
+
annotated,
|
|
1043
|
+
text,
|
|
1044
|
+
(text_x, text_y),
|
|
1045
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
1046
|
+
font_scale,
|
|
1047
|
+
color,
|
|
1048
|
+
thickness,
|
|
1049
|
+
cv2.LINE_AA
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
return annotated
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
############################################################
|
|
1056
|
+
# Get pixel-level color histograms for all fruits #
|
|
1057
|
+
############################################################
|
|
1058
|
+
|
|
1059
|
+
def get_fruit_color_histograms(
|
|
1060
|
+
img: np.ndarray,
|
|
1061
|
+
hsv_img: Optional[np.ndarray],
|
|
1062
|
+
mask: np.ndarray,
|
|
1063
|
+
contours: List[np.ndarray],
|
|
1064
|
+
fruit_locule_map: Dict[int, List[int]],
|
|
1065
|
+
image_name: str = '',
|
|
1066
|
+
label: str = '',
|
|
1067
|
+
color_space: str = 'all',
|
|
1068
|
+
renumber: bool = True,
|
|
1069
|
+
dark_threshold: int = 0,
|
|
1070
|
+
normalize: bool = False
|
|
1071
|
+
) -> List[Dict[str, float]]:
|
|
1072
|
+
"""
|
|
1073
|
+
Create pixel-level color histograms for all fruits.
|
|
1074
|
+
|
|
1075
|
+
For each fruit, extracts the pixel distribution across each color
|
|
1076
|
+
channel for the total pericarp region (or total fruit region if
|
|
1077
|
+
external analysis) only.
|
|
1078
|
+
|
|
1079
|
+
Each bin in the histogram corresponds to one integer intensity value
|
|
1080
|
+
within the channel's valid range.
|
|
1081
|
+
|
|
1082
|
+
Parameters
|
|
1083
|
+
----------
|
|
1084
|
+
img : np.ndarray
|
|
1085
|
+
Input image in BGR format.
|
|
1086
|
+
hsv_img : np.ndarray or None
|
|
1087
|
+
Precomputed HSV image. If None, it is created internally.
|
|
1088
|
+
mask : np.ndarray
|
|
1089
|
+
Binary segmentation mask where fruit pixels are > 0.
|
|
1090
|
+
contours : List[np.ndarray]
|
|
1091
|
+
List of all contours (fruits and locules).
|
|
1092
|
+
fruit_locule_map : Dict[int, List[int]]
|
|
1093
|
+
Mapping of fruit contour indices to their locule contour indices.
|
|
1094
|
+
image_name : str, optional
|
|
1095
|
+
Image identifier included in each output row. Default is ``''``.
|
|
1096
|
+
label : str, optional
|
|
1097
|
+
Sample label included in each output row. Default is ``''``.
|
|
1098
|
+
color_space : str, optional
|
|
1099
|
+
Color spaces to include. Either ``'all'`` or a comma-separated
|
|
1100
|
+
subset of ``'rgb'``, ``'lab'``, ``'hsv'``, ``'gray'``.
|
|
1101
|
+
Default is ``'all'``.
|
|
1102
|
+
renumber : bool, optional
|
|
1103
|
+
If True, fruit IDs are renumbered from 1 to n. Default is True.
|
|
1104
|
+
dark_threshold : int, optional
|
|
1105
|
+
Grayscale intensity threshold below which pixels are excluded.
|
|
1106
|
+
Default is 0 (no exclusion).
|
|
1107
|
+
normalize : bool, optional
|
|
1108
|
+
If True, bin counts are divided by the total number of valid pixels.
|
|
1109
|
+
Default is False.
|
|
1110
|
+
|
|
1111
|
+
Returns
|
|
1112
|
+
-------
|
|
1113
|
+
List[Dict[str, float]]
|
|
1114
|
+
One dictionary per fruit with keys ``'image_name'``, ``'label'``,
|
|
1115
|
+
``'fruit_id'``, and one key per histogram bin (e.g., ``'R_0'``
|
|
1116
|
+
through ``'R_255'``). Returns NaN values for fruits with no
|
|
1117
|
+
valid pixels.
|
|
1118
|
+
|
|
1119
|
+
Raises
|
|
1120
|
+
------
|
|
1121
|
+
ValueError
|
|
1122
|
+
If ``color_space`` contains invalid entries.
|
|
1123
|
+
"""
|
|
1124
|
+
|
|
1125
|
+
# Validate color_space
|
|
1126
|
+
color_space = color_space.lower().replace(' ', '')
|
|
1127
|
+
spaces = {'rgb', 'lab', 'hsv', 'gray'} if color_space == 'all' else set(color_space.split(','))
|
|
1128
|
+
valid_spaces = {'rgb', 'lab', 'hsv', 'gray'}
|
|
1129
|
+
if not spaces.issubset(valid_spaces):
|
|
1130
|
+
raise ValueError(f"Invalid color_space: {spaces - valid_spaces}. Valid: {valid_spaces}")
|
|
1131
|
+
|
|
1132
|
+
# Define histogram bins ranges per channel
|
|
1133
|
+
|
|
1134
|
+
# For each entry return column_prefix, bin_start, bin_stop_inclusive (bin values are integers after rounding the normalized float pixels)
|
|
1135
|
+
channel_specs: Dict[str, Tuple[int, int]] = {} # ch_name -> (min_val, max_val inclusive)
|
|
1136
|
+
|
|
1137
|
+
if 'rgb' in spaces:
|
|
1138
|
+
channel_specs.update({'R': (0, 255), 'G': (0, 255), 'B': (0, 255)})
|
|
1139
|
+
if 'lab' in spaces:
|
|
1140
|
+
channel_specs.update({'L': (0, 100), 'a': (-128, 127), 'b': (-128, 127)})
|
|
1141
|
+
if 'hsv' in spaces:
|
|
1142
|
+
channel_specs.update({'H': (0, 359), 'S': (0, 100), 'V': (0, 100)})
|
|
1143
|
+
if 'gray' in spaces:
|
|
1144
|
+
channel_specs['Gray'] = (0, 255)
|
|
1145
|
+
|
|
1146
|
+
# Pre-build column names in a fixed order
|
|
1147
|
+
all_col_names: List[str] = [
|
|
1148
|
+
f'{ch}_{i}'
|
|
1149
|
+
for ch, (lo, hi) in channel_specs.items()
|
|
1150
|
+
for i in range(lo, hi + 1)
|
|
1151
|
+
]
|
|
1152
|
+
|
|
1153
|
+
def _nan_row(fruit_id: int) -> Dict:
|
|
1154
|
+
row = {'image_name': image_name, 'label': label, 'fruit_id': fruit_id}
|
|
1155
|
+
row.update({col: np.nan for col in all_col_names})
|
|
1156
|
+
return row
|
|
1157
|
+
|
|
1158
|
+
def _hist_from_float(values: np.ndarray, lo: int, hi: int, n_pix: int) -> np.ndarray:
|
|
1159
|
+
"""
|
|
1160
|
+
Round normalized float pixel values to int, clamp to [lo, hi],
|
|
1161
|
+
and return a count array of length (hi - lo + 1).
|
|
1162
|
+
Optionally divide by n_pix if normalize=True.
|
|
1163
|
+
"""
|
|
1164
|
+
rounded = np.round(values).astype(np.int32)
|
|
1165
|
+
rounded = np.clip(rounded, lo, hi)
|
|
1166
|
+
# Shift so index 0 = lo
|
|
1167
|
+
shifted = rounded - lo
|
|
1168
|
+
n_bins = hi - lo + 1
|
|
1169
|
+
counts = np.bincount(shifted, minlength=n_bins).astype(np.float64)
|
|
1170
|
+
if normalize:
|
|
1171
|
+
counts /= n_pix
|
|
1172
|
+
return counts
|
|
1173
|
+
|
|
1174
|
+
if hsv_img is None:
|
|
1175
|
+
hsv_image = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
|
1176
|
+
else:
|
|
1177
|
+
hsv_image = hsv_img
|
|
1178
|
+
|
|
1179
|
+
# Convert full image to each color space once
|
|
1180
|
+
gray_full = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
1181
|
+
lab_full = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) if 'lab' in spaces else None
|
|
1182
|
+
hsv_full = hsv_image if 'hsv' in spaces else None
|
|
1183
|
+
|
|
1184
|
+
# Renumber fruit IDs
|
|
1185
|
+
if renumber:
|
|
1186
|
+
iter_map, fruit_id_map = renumber_fruit_locule_map(fruit_locule_map)
|
|
1187
|
+
else:
|
|
1188
|
+
iter_map = fruit_locule_map
|
|
1189
|
+
fruit_id_map = {k: k for k in fruit_locule_map.keys()}
|
|
1190
|
+
|
|
1191
|
+
rows: List[Dict] = []
|
|
1192
|
+
|
|
1193
|
+
for fruit_id, locule_indices in iter_map.items():
|
|
1194
|
+
original_fruit_id = fruit_id_map[fruit_id]
|
|
1195
|
+
|
|
1196
|
+
if original_fruit_id >= len(contours):
|
|
1197
|
+
rows.append(_nan_row(fruit_id))
|
|
1198
|
+
continue
|
|
1199
|
+
|
|
1200
|
+
fruit_contour = contours[original_fruit_id]
|
|
1201
|
+
if fruit_contour is None or len(fruit_contour) == 0:
|
|
1202
|
+
rows.append(_nan_row(fruit_id))
|
|
1203
|
+
continue
|
|
1204
|
+
|
|
1205
|
+
# Bounding box crop
|
|
1206
|
+
x, y, w, h = cv2.boundingRect(fruit_contour)
|
|
1207
|
+
roi_img = img [y:y+h, x:x+w]
|
|
1208
|
+
roi_mask = mask [y:y+h, x:x+w]
|
|
1209
|
+
roi_gray = gray_full[y:y+h, x:x+w]
|
|
1210
|
+
|
|
1211
|
+
# Valid pixel mask (total_pericarp, locules excluded)
|
|
1212
|
+
valid = (roi_mask > 0) & (roi_gray > dark_threshold)
|
|
1213
|
+
|
|
1214
|
+
if locule_indices:
|
|
1215
|
+
loc_mask = np.zeros((h, w), dtype=np.uint8)
|
|
1216
|
+
for loc_idx in locule_indices:
|
|
1217
|
+
if loc_idx >= len(contours):
|
|
1218
|
+
continue
|
|
1219
|
+
lc = contours[loc_idx]
|
|
1220
|
+
if lc is None or len(lc) == 0:
|
|
1221
|
+
continue
|
|
1222
|
+
lc = lc.copy()
|
|
1223
|
+
lc[:, :, 0] -= x
|
|
1224
|
+
lc[:, :, 1] -= y
|
|
1225
|
+
cv2.drawContours(loc_mask, [lc], -1, 255, cv2.FILLED)
|
|
1226
|
+
valid &= (loc_mask == 0)
|
|
1227
|
+
|
|
1228
|
+
if not np.any(valid):
|
|
1229
|
+
rows.append(_nan_row(fruit_id))
|
|
1230
|
+
continue
|
|
1231
|
+
|
|
1232
|
+
n_pixels = int(valid.sum())
|
|
1233
|
+
row: Dict = {'image_name': image_name, 'label': label, 'fruit_id': fruit_id}
|
|
1234
|
+
|
|
1235
|
+
# RGB
|
|
1236
|
+
if 'rgb' in spaces:
|
|
1237
|
+
bgr_roi = roi_img[valid] # Nx3
|
|
1238
|
+
for ch_name, ch_idx in (('R', 2), ('G', 1), ('B', 0)):
|
|
1239
|
+
lo, hi = channel_specs[ch_name]
|
|
1240
|
+
counts = _hist_from_float(bgr_roi[:, ch_idx].astype(np.float32), lo, hi, n_pixels)
|
|
1241
|
+
for i, v in zip(range(lo, hi + 1), counts):
|
|
1242
|
+
row[f'{ch_name}_{i}'] = float(v)
|
|
1243
|
+
|
|
1244
|
+
# Lab
|
|
1245
|
+
if 'lab' in spaces:
|
|
1246
|
+
roi_lab = lab_full[y:y+h, x:x+w]
|
|
1247
|
+
l_raw = roi_lab[:, :, 0][valid]
|
|
1248
|
+
a_raw = roi_lab[:, :, 1][valid]
|
|
1249
|
+
b_raw = roi_lab[:, :, 2][valid]
|
|
1250
|
+
# Reuse existing normalization function
|
|
1251
|
+
l_norm, a_norm, b_norm = normalize_lab_values(l_raw, a_raw, b_raw)
|
|
1252
|
+
# L: 0-100, a/b: -128 to 127
|
|
1253
|
+
for ch_name, vals in (('L', l_norm), ('a', a_norm), ('b', b_norm)):
|
|
1254
|
+
lo, hi = channel_specs[ch_name]
|
|
1255
|
+
counts = _hist_from_float(vals, lo, hi, n_pixels)
|
|
1256
|
+
for i, v in zip(range(lo, hi + 1), counts):
|
|
1257
|
+
row[f'{ch_name}_{i}'] = float(v)
|
|
1258
|
+
|
|
1259
|
+
# HSV
|
|
1260
|
+
if 'hsv' in spaces:
|
|
1261
|
+
roi_hsv = hsv_full[y:y+h, x:x+w]
|
|
1262
|
+
h_raw = roi_hsv[:, :, 0][valid]
|
|
1263
|
+
s_raw = roi_hsv[:, :, 1][valid]
|
|
1264
|
+
v_raw = roi_hsv[:, :, 2][valid]
|
|
1265
|
+
# Reuse existing normalization: H→0-360, S/V→0-100
|
|
1266
|
+
h_norm, s_norm, v_norm = normalize_hsv_values(h_raw, s_raw, v_raw)
|
|
1267
|
+
for ch_name, vals in (('H', h_norm), ('S', s_norm), ('V', v_norm)):
|
|
1268
|
+
lo, hi = channel_specs[ch_name]
|
|
1269
|
+
counts = _hist_from_float(vals, lo, hi, n_pixels)
|
|
1270
|
+
for i, v in zip(range(lo, hi + 1), counts):
|
|
1271
|
+
row[f'{ch_name}_{i}'] = float(v)
|
|
1272
|
+
|
|
1273
|
+
# Gray
|
|
1274
|
+
if 'gray' in spaces:
|
|
1275
|
+
lo, hi = channel_specs['Gray']
|
|
1276
|
+
counts = _hist_from_float(roi_gray[valid].astype(np.float32), lo, hi, n_pixels)
|
|
1277
|
+
for i, v in zip(range(lo, hi + 1), counts):
|
|
1278
|
+
row[f'Gray_{i}'] = float(v)
|
|
1279
|
+
|
|
1280
|
+
rows.append(row)
|
|
1281
|
+
|
|
1282
|
+
return rows
|
|
1283
|
+
|
|
1284
|
+
# ============================================================================
|
|
1285
|
+
# Calculate hue index for fruit color variation (red-yellow)
|
|
1286
|
+
# ============================================================================
|
|
1287
|
+
|
|
1288
|
+
def calculate_hue_index(
|
|
1289
|
+
df: pd.DataFrame,
|
|
1290
|
+
red_hue_ranges: List[Tuple[int, int]] = [(0, 21), (250, 360)],
|
|
1291
|
+
yellow_hue_range: Tuple[int, int] = (40, 80),
|
|
1292
|
+
orange_hue_range: Optional[Tuple[int, int]] = (22, 39),
|
|
1293
|
+
homogeneity_threshold: float = 0.80,
|
|
1294
|
+
) -> pd.DataFrame:
|
|
1295
|
+
"""
|
|
1296
|
+
Calculate a red/yellow/orange color index per fruit from hue histograms.
|
|
1297
|
+
|
|
1298
|
+
Classifies each fruit into a dominant color category based on the
|
|
1299
|
+
proportion of pixels falling within defined hue ranges. A fruit is
|
|
1300
|
+
labeled with its dominant color only if that color exceeds the
|
|
1301
|
+
homogeneity threshold; otherwise it is labeled ``'mixed'``.
|
|
1302
|
+
|
|
1303
|
+
Parameters
|
|
1304
|
+
----------
|
|
1305
|
+
df : pd.DataFrame
|
|
1306
|
+
DataFrame containing hue histogram columns (``'H_0'`` through
|
|
1307
|
+
``'H_359'``) as produced by :func:`get_fruit_color_histograms`.
|
|
1308
|
+
red_hue_ranges : List[Tuple[int, int]], optional
|
|
1309
|
+
List of (min, max) hue degree ranges considered red.
|
|
1310
|
+
Default is ``[(0, 21), (250, 360)]``.
|
|
1311
|
+
yellow_hue_range : Tuple[int, int], optional
|
|
1312
|
+
(min, max) hue degree range considered yellow. Default is ``(40, 80)``.
|
|
1313
|
+
orange_hue_range : Tuple[int, int] or None, optional
|
|
1314
|
+
(min, max) hue degree range considered orange. Set to None to
|
|
1315
|
+
disable orange classification. Default is ``(22, 39)``.
|
|
1316
|
+
homogeneity_threshold : float, optional
|
|
1317
|
+
Minimum dominant color ratio required to assign a single color
|
|
1318
|
+
category. Default is 0.80.
|
|
1319
|
+
|
|
1320
|
+
Returns
|
|
1321
|
+
-------
|
|
1322
|
+
pd.DataFrame
|
|
1323
|
+
One row per fruit with columns: ``'image_name'``, ``'fruit_id'``,
|
|
1324
|
+
``'red_pixels'``, ``'yellow_pixels'``, ``'orange_pixels'`` (if enabled),
|
|
1325
|
+
``'total_hue_pixels'``, ``'red_ratio'``, ``'yellow_ratio'``,
|
|
1326
|
+
``'orange_ratio'`` (if enabled), ``'color_homogeneity'``,
|
|
1327
|
+
``'color_category'``, and ``'dominant_color'``.
|
|
1328
|
+
|
|
1329
|
+
Raises
|
|
1330
|
+
------
|
|
1331
|
+
ValueError
|
|
1332
|
+
If required hue histogram columns are missing from ``df``.
|
|
1333
|
+
"""
|
|
1334
|
+
|
|
1335
|
+
h_cols_all = [f'H_{i}' for i in range(360)]
|
|
1336
|
+
missing = [c for c in h_cols_all if c not in df.columns]
|
|
1337
|
+
if missing:
|
|
1338
|
+
raise ValueError(
|
|
1339
|
+
"Missing Hue columns in DataFrame. "
|
|
1340
|
+
"Run analyze_color() with get_color_histogram=True and color_space='hsv' first."
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
# Build bin index sets
|
|
1344
|
+
red_bins = set()
|
|
1345
|
+
for lo, hi in red_hue_ranges:
|
|
1346
|
+
red_bins.update(range(lo, hi + 1))
|
|
1347
|
+
|
|
1348
|
+
y_lo, y_hi = yellow_hue_range
|
|
1349
|
+
yellow_bins = set(range(y_lo, y_hi + 1))
|
|
1350
|
+
|
|
1351
|
+
use_orange = orange_hue_range is not None
|
|
1352
|
+
if use_orange:
|
|
1353
|
+
o_lo, o_hi = orange_hue_range
|
|
1354
|
+
orange_bins = set(range(o_lo, o_hi + 1))
|
|
1355
|
+
else:
|
|
1356
|
+
orange_bins = set()
|
|
1357
|
+
|
|
1358
|
+
red_cols = [f'H_{i}' for i in sorted(red_bins) if f'H_{i}' in df.columns]
|
|
1359
|
+
yellow_cols = [f'H_{i}' for i in sorted(yellow_bins) if f'H_{i}' in df.columns]
|
|
1360
|
+
orange_cols = [f'H_{i}' for i in sorted(orange_bins) if f'H_{i}' in df.columns]
|
|
1361
|
+
|
|
1362
|
+
records = []
|
|
1363
|
+
for _, row in df.iterrows():
|
|
1364
|
+
red_px = row[red_cols].sum()
|
|
1365
|
+
yellow_px = row[yellow_cols].sum()
|
|
1366
|
+
orange_px = row[orange_cols].sum() if use_orange else 0
|
|
1367
|
+
|
|
1368
|
+
total_px = red_px + yellow_px + orange_px
|
|
1369
|
+
|
|
1370
|
+
if total_px == 0:
|
|
1371
|
+
red_ratio = np.nan
|
|
1372
|
+
yellow_ratio = np.nan
|
|
1373
|
+
orange_ratio = np.nan if use_orange else None
|
|
1374
|
+
color_homogeneity = np.nan
|
|
1375
|
+
color_category = 'unknown'
|
|
1376
|
+
else:
|
|
1377
|
+
red_ratio = red_px / total_px
|
|
1378
|
+
yellow_ratio = yellow_px / total_px
|
|
1379
|
+
orange_ratio = orange_px / total_px if use_orange else None
|
|
1380
|
+
|
|
1381
|
+
ratios = {'red': red_ratio, 'yellow': yellow_ratio}
|
|
1382
|
+
if use_orange:
|
|
1383
|
+
ratios['orange'] = orange_ratio
|
|
1384
|
+
|
|
1385
|
+
color_homogeneity = float(max(ratios.values()))
|
|
1386
|
+
dominant = max(ratios, key=ratios.get)
|
|
1387
|
+
color_category = dominant if color_homogeneity >= homogeneity_threshold else 'mixed'
|
|
1388
|
+
dominant_color = dominant
|
|
1389
|
+
|
|
1390
|
+
record = {
|
|
1391
|
+
'image_name': row.get('image_name', ''),
|
|
1392
|
+
'fruit_id': row['fruit_id'],
|
|
1393
|
+
'red_pixels': int(red_px),
|
|
1394
|
+
'orange_pixels': int(orange_px) if use_orange else None,
|
|
1395
|
+
'yellow_pixels': int(yellow_px),
|
|
1396
|
+
'total_hue_pixels': int(total_px),
|
|
1397
|
+
'red_ratio': round(float(red_ratio), 4) if not np.isnan(red_ratio) else np.nan,
|
|
1398
|
+
'orange_ratio': round(float(orange_ratio), 4) if use_orange and not np.isnan(orange_ratio) else None,
|
|
1399
|
+
'yellow_ratio': round(float(yellow_ratio), 4) if not np.isnan(yellow_ratio) else np.nan,
|
|
1400
|
+
'color_homogeneity': round(float(color_homogeneity), 4) if not np.isnan(color_homogeneity) else np.nan,
|
|
1401
|
+
'color_category': color_category,
|
|
1402
|
+
'dominant_color': dominant_color if total_px > 0 else 'unknown',
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
# Drop None columns when orange is disabled
|
|
1406
|
+
if not use_orange:
|
|
1407
|
+
record.pop('orange_pixels')
|
|
1408
|
+
record.pop('orange_ratio')
|
|
1409
|
+
|
|
1410
|
+
records.append(record)
|
|
1411
|
+
|
|
1412
|
+
return pd.DataFrame(records)
|