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.
@@ -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)