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 ADDED
@@ -0,0 +1,39 @@
1
+ # traitly/__init__.py
2
+ """
3
+ Traitly: Phenotyping analysis of fruits in images using computer vision.
4
+ """
5
+ import re
6
+ from importlib.metadata import version, metadata, PackageNotFoundError
7
+
8
+ try:
9
+ _meta = metadata("traitly")
10
+ __version__ = version("traitly")
11
+
12
+ _raw = _meta.get("Author-email") or ""
13
+ # Separar por coma fuera de los < >
14
+ _entries = re.split(r",\s*(?=[^<>]*(?:<|$))", _raw)
15
+ __authors__ = [
16
+ re.match(r"^(.*?)\s*<", a).group(1).strip()
17
+ for a in _entries if "<" in a
18
+ ]
19
+ __author__ = ", ".join(__authors__)
20
+
21
+ except PackageNotFoundError:
22
+ __version__ = "unknown"
23
+ __authors__ = []
24
+ __author__ = "unknown"
25
+
26
+
27
+ # Import functions from utils
28
+ from .utils.basic_functions import (
29
+ load_img,
30
+ plot_img
31
+ )
32
+
33
+ __all__ = [
34
+ '__version__',
35
+ '__author__',
36
+ '__authors__',
37
+ 'load_img',
38
+ 'plot_img'
39
+ ]
traitly/cli.py ADDED
@@ -0,0 +1,380 @@
1
+ # traitly/cli.py
2
+ """
3
+ Command-line interface for Traitly fruit phenotyping.
4
+
5
+ Provides the ``traitly`` entry point for running internal and external
6
+ fruit analysis pipelines directly from the terminal, on single images
7
+ or entire folders.
8
+
9
+ Usage
10
+ -----
11
+ .. code-block:: bash
12
+
13
+ traitly --fruit_internal -i PATH [-o PATH] [--json PATH] [--num_cores N]
14
+ traitly --fruit_external -i PATH [-o PATH] [--json PATH] [--num_cores N]
15
+
16
+ For folder inputs, delegates to
17
+ :meth:`~traitly.fruit_phenotyping.internal_analysis.FruitInternalAnalyzer.analyze_folder`
18
+ or
19
+ :meth:`~traitly.fruit_phenotyping.external_analysis.FruitExternalAnalyzer.analyze_folder`.
20
+ For single images, delegates to
21
+ :meth:`~traitly.fruit_phenotyping.internal_analysis.FruitInternalAnalyzer.process_single_file`.
22
+ """
23
+
24
+ import argparse
25
+ import sys
26
+ import os
27
+ from pathlib import Path
28
+
29
+ try:
30
+ from tabulate import tabulate as _tabulate
31
+ def _fmt_examples(rows):
32
+ return _tabulate(rows, tablefmt='plain')
33
+ except ImportError:
34
+ def _fmt_examples(rows):
35
+ return "\n".join(f" {r[0]}" for r in rows)
36
+
37
+
38
+ # ============================================================================
39
+ # Parser
40
+ # ============================================================================
41
+
42
+ def create_parser() -> argparse.ArgumentParser:
43
+ """
44
+ Build and return the CLI argument parser for Traitly.
45
+
46
+ Defines a mutually exclusive mode group (``--fruit_internal`` /
47
+ ``--fruit_external``), required and optional arguments, and a
48
+ formatted epilog with usage examples.
49
+
50
+ Returns
51
+ -------
52
+ argparse.ArgumentParser
53
+ Configured parser ready to call ``.parse_args()`` on.
54
+ """
55
+ parser = argparse.ArgumentParser(
56
+ prog='traitly',
57
+ description='Traitly - Fruit Phenotyping Tool',
58
+ formatter_class=argparse.RawDescriptionHelpFormatter,
59
+ epilog="\n".join([
60
+ "",
61
+ "Examples:",
62
+ _fmt_examples([
63
+ (" # Internal structure analysis (single image or folder)",),
64
+ (" traitly --fruit_internal -i tests/sample_data/",),
65
+ (" traitly --fruit_internal -i tests/sample_data/ -o results/ --num_cores 4",),
66
+ (" traitly --fruit_internal -i tests/sample_data/ --json config.json",),
67
+ ("",),
68
+ (" # External analysis (single image or folder)",),
69
+ (" traitly --fruit_external -i tests/sample_data/",),
70
+ (" traitly --fruit_external -i tests/sample_data/ -o results/ --json config.json --num_cores 4",),
71
+ ]),
72
+ "",
73
+ "For more info: https://github.com/mariameraz/traitly",
74
+ "",
75
+ ])
76
+ )
77
+
78
+ # Mode (mutually exclusive)
79
+ mode_group = parser.add_mutually_exclusive_group(required=True)
80
+ mode_group.add_argument(
81
+ '--fruit_internal',
82
+ action='store_true',
83
+ help='Analyze internal fruit structure (locules, pericarp, symmetry)'
84
+ )
85
+ mode_group.add_argument(
86
+ '--fruit_external',
87
+ action='store_true',
88
+ help='Analyze external fruit structure (morphology, color)'
89
+ )
90
+
91
+ # Required
92
+ parser.add_argument(
93
+ '-i', '--input',
94
+ type=str,
95
+ required=True,
96
+ metavar='PATH',
97
+ help='Path to image file or folder'
98
+ )
99
+
100
+ # Optional
101
+ parser.add_argument(
102
+ '-o', '--output',
103
+ type=str,
104
+ default=None,
105
+ metavar='PATH',
106
+ help='Output directory (default: <input>/Results/)'
107
+ )
108
+
109
+ parser.add_argument(
110
+ '--json',
111
+ type=str,
112
+ default=None,
113
+ metavar='PATH',
114
+ help='Path to JSON config file with analysis parameters'
115
+ )
116
+
117
+ parser.add_argument(
118
+ '--num_cores',
119
+ type=int,
120
+ default=1,
121
+ metavar='N',
122
+ help='Number of CPU cores for parallel processing (default: 1)'
123
+ )
124
+
125
+ parser.add_argument(
126
+ '--no_morphology',
127
+ action='store_true',
128
+ help='Skip morphology analysis'
129
+ )
130
+
131
+ parser.add_argument(
132
+ '--no_color',
133
+ action='store_true',
134
+ help='Skip color analysis'
135
+ )
136
+
137
+ parser.add_argument(
138
+ '--version',
139
+ action='version',
140
+ version='%(prog)s 0.1.0'
141
+ )
142
+
143
+ return parser
144
+
145
+
146
+ # ============================================================================
147
+ # Helpers
148
+ # ============================================================================
149
+
150
+ def _validate_input(path_str: str) -> Path:
151
+ """
152
+ Validate that the input path exists and return it as a :class:`Path`.
153
+
154
+ Prints an error message and calls ``sys.exit(1)`` if the path does
155
+ not exist.
156
+
157
+ Parameters
158
+ ----------
159
+ path_str : str
160
+ Raw input path string from the CLI argument.
161
+
162
+ Returns
163
+ -------
164
+ Path
165
+ Validated :class:`~pathlib.Path` object.
166
+ """
167
+ path = Path(path_str)
168
+ if not path.exists():
169
+ print(f">> Error: Path does not exist: {path_str}")
170
+ sys.exit(1)
171
+ return path
172
+
173
+
174
+ def _validate_json(json_str: str) -> str:
175
+ """
176
+ Validate that the JSON config file exists if provided.
177
+
178
+ Prints an error message and calls ``sys.exit(1)`` if ``json_str``
179
+ is not ``None`` and the file does not exist.
180
+
181
+ Parameters
182
+ ----------
183
+ json_str : str or None
184
+ Path to the JSON config file, or ``None`` if not provided.
185
+
186
+ Returns
187
+ -------
188
+ str or None
189
+ The original ``json_str`` if valid, or ``None``.
190
+ """
191
+ if json_str is not None and not Path(json_str).exists():
192
+ print(f">> Error: JSON config file does not exist: {json_str}")
193
+ sys.exit(1)
194
+ return json_str
195
+
196
+
197
+ # ============================================================================
198
+ # Internal analysis
199
+ # ============================================================================
200
+
201
+ def run_internal(args: argparse.Namespace) -> None:
202
+ """
203
+ Run the internal fruit analysis pipeline from the CLI.
204
+
205
+ Instantiates :class:`~traitly.fruit_phenotyping.internal_analysis.FruitInternalAnalyzer`
206
+ and dispatches to:
207
+
208
+ - :meth:`~traitly.fruit_phenotyping.internal_analysis.FruitInternalAnalyzer.analyze_folder`
209
+ when ``args.input`` is a directory.
210
+ - :meth:`~traitly.fruit_phenotyping.internal_analysis.FruitInternalAnalyzer.process_single_file`
211
+ when ``args.input`` is a single image file.
212
+
213
+ Parameters
214
+ ----------
215
+ args : argparse.Namespace
216
+ Parsed CLI arguments containing ``input``, ``output``, ``json``,
217
+ ``num_cores``, ``no_morphology``, and ``no_color``.
218
+ """
219
+ from traitly.fruit_phenotyping.internal_analysis import FruitInternalAnalyzer
220
+
221
+ path = _validate_input(args.input)
222
+ json_path = _validate_json(args.json)
223
+
224
+ try:
225
+ analyzer = FruitInternalAnalyzer(str(path))
226
+ except Exception as e:
227
+ print(f">> Error initializing FruitInternalAnalyzer: {e}")
228
+ sys.exit(1)
229
+
230
+ analyze_morphology = not args.no_morphology
231
+ analyze_color = not args.no_color
232
+
233
+ if path.is_dir():
234
+ try:
235
+ analyzer.analyze_folder(
236
+ analyze_morphology=analyze_morphology,
237
+ analyze_color=analyze_color,
238
+ json_path=json_path,
239
+ output_path=args.output,
240
+ num_cores=args.num_cores,
241
+ verbose=True,
242
+ )
243
+ except Exception as e:
244
+ print(f">> Error during internal folder analysis: {e}")
245
+ import traceback; traceback.print_exc()
246
+ sys.exit(1)
247
+
248
+ else:
249
+ # Single image — process_single_file
250
+ import json as _json
251
+ config = {}
252
+ if json_path:
253
+ with open(json_path, 'r', encoding='utf-8') as f:
254
+ config = _json.load(f) or {}
255
+
256
+ try:
257
+ analyzer.load_image(plot=False)
258
+ df_m, df_c, err, n_fruits, ann_img = analyzer.process_single_file(
259
+ config=config,
260
+ json_path=None,
261
+ analyze_morphology=analyze_morphology,
262
+ analyze_color=analyze_color,
263
+ save_image=True,
264
+ output_path=args.output,
265
+ )
266
+ if err:
267
+ print(f">> Error processing image: {err.get('status', 'Unknown error')}")
268
+ sys.exit(1)
269
+ print(f"Done. Fruits detected: {n_fruits}")
270
+ except Exception as e:
271
+ print(f">> Error during internal image analysis: {e}")
272
+ import traceback; traceback.print_exc()
273
+ sys.exit(1)
274
+
275
+
276
+ # ============================================================================
277
+ # External analysis
278
+ # ============================================================================
279
+
280
+ def run_external(args: argparse.Namespace) -> None:
281
+ """
282
+ Run the external fruit analysis pipeline from the CLI.
283
+
284
+ Instantiates :class:`~traitly.fruit_phenotyping.external_analysis.FruitExternalAnalyzer`
285
+ and dispatches to:
286
+
287
+ - :meth:`~traitly.fruit_phenotyping.external_analysis.FruitExternalAnalyzer.analyze_folder`
288
+ when ``args.input`` is a directory.
289
+ - :meth:`~traitly.fruit_phenotyping.external_analysis.FruitExternalAnalyzer.process_single_file`
290
+ when ``args.input`` is a single image file.
291
+
292
+ Parameters
293
+ ----------
294
+ args : argparse.Namespace
295
+ Parsed CLI arguments containing ``input``, ``output``, ``json``,
296
+ ``num_cores``, ``no_morphology``, and ``no_color``.
297
+ """
298
+ from traitly.fruit_phenotyping.external_analysis import FruitExternalAnalyzer
299
+
300
+ path = _validate_input(args.input)
301
+ json_path = _validate_json(args.json)
302
+
303
+ try:
304
+ analyzer = FruitExternalAnalyzer(str(path))
305
+ except Exception as e:
306
+ print(f">> Error initializing FruitExternalAnalyzer: {e}")
307
+ sys.exit(1)
308
+
309
+ analyze_morphology = not args.no_morphology
310
+ analyze_color = not args.no_color
311
+
312
+ if path.is_dir():
313
+ try:
314
+ analyzer.analyze_folder(
315
+ analyze_morphology=analyze_morphology,
316
+ analyze_color=analyze_color,
317
+ json_path=json_path,
318
+ output_path=args.output,
319
+ num_cores=args.num_cores,
320
+ verbose=True,
321
+ )
322
+ except Exception as e:
323
+ print(f">> Error during external folder analysis: {e}")
324
+ import traceback; traceback.print_exc()
325
+ sys.exit(1)
326
+
327
+ else:
328
+ # Single image
329
+ import json as _json
330
+ config = {}
331
+ if json_path:
332
+ with open(json_path, 'r', encoding='utf-8') as f:
333
+ config = _json.load(f) or {}
334
+
335
+ try:
336
+ analyzer.load_image(plot=False)
337
+ df_m, df_c, err, n_fruits, ann_img = analyzer.process_single_file(
338
+ config=config,
339
+ json_path=None,
340
+ analyze_morphology=analyze_morphology,
341
+ analyze_color=analyze_color,
342
+ save_image=True,
343
+ output_path=args.output,
344
+ )
345
+ if err:
346
+ print(f">> Error processing image: {err.get('status', 'Unknown error')}")
347
+ sys.exit(1)
348
+ print(f"Done. Fruits detected: {n_fruits}")
349
+ except Exception as e:
350
+ print(f">> Error during external image analysis: {e}")
351
+ import traceback; traceback.print_exc()
352
+ sys.exit(1)
353
+
354
+
355
+ # ============================================================================
356
+ # Entry point
357
+ # ============================================================================
358
+
359
+ def main() -> None:
360
+ """
361
+ Entry point for the ``traitly`` CLI command.
362
+
363
+ Parses arguments via :func:`create_parser` and dispatches to
364
+ :func:`run_internal` or :func:`run_external` based on the selected
365
+ mode flag. Prints help and exits cleanly if neither flag is set.
366
+ """
367
+ parser = create_parser()
368
+ args = parser.parse_args()
369
+
370
+ if args.fruit_internal:
371
+ run_internal(args)
372
+ elif args.fruit_external:
373
+ run_external(args)
374
+ else:
375
+ parser.print_help()
376
+ sys.exit(0)
377
+
378
+
379
+ if __name__ == '__main__':
380
+ main()
@@ -0,0 +1,20 @@
1
+ from .internal_analysis import FruitInternalAnalyzer
2
+ from .external_analysis import FruitExternalAnalyzer
3
+
4
+ from .color_plot import (plot_color_scatter,
5
+ plot_color_histogram,
6
+ plot_color_correlation,
7
+ plot_dark_threshold,
8
+ plot_tissue_colors)
9
+
10
+ from .color_analysis import calculate_hue_index
11
+
12
+ __all__ = ['FruitInternalAnalyzer',
13
+ 'FruitExternalAnalyzer',
14
+ 'plot_color_scatter',
15
+ 'plot_color_histogram',
16
+ 'plot_color_correlation',
17
+ 'calculate_hue_index',
18
+ 'plot_dark_threshold',
19
+ 'plot_tissue_colors']
20
+
@@ -0,0 +1,192 @@
1
+ # traitly/fruit_phenotyping/analysis_parameters.py
2
+ """
3
+ Analysis metadata tracking for traitly `FruitInternalAnalyzer` and
4
+ `FruitExternalAnalyzer` pipelines.
5
+
6
+ Provides the :class:`AnalysisParameters` dataclass for capturing and
7
+ exporting the processing parameters used in each stage of the fruit
8
+ phenotyping pipeline, supporting reproducibility and traceability.
9
+ """
10
+
11
+ # ============================================================================
12
+ # STANDARD LIBRARY
13
+ # ============================================================================
14
+ import importlib.metadata
15
+ import json
16
+ import sys
17
+ from dataclasses import asdict, dataclass, field
18
+ from datetime import datetime
19
+ from typing import Any, Dict
20
+
21
+ # ============================================================================
22
+ # INTERNAL IMPORTS
23
+ # ============================================================================
24
+ from traitly import __version__
25
+
26
+ # ============================================================================
27
+ # Save parameters used and session information as txt and json files
28
+ # ============================================================================
29
+
30
+
31
+ @dataclass
32
+ class AnalysisParameters:
33
+ """
34
+ Save and report processing parameters for each analysis step.
35
+
36
+ Captures the parameters used in each stage of the analysis pipeline
37
+ and provides methods to export them as formatted text or JSON for
38
+ reproducibility and traceability.
39
+
40
+ Attributes
41
+ ----------
42
+ setup_measurements_params : Dict[str, Any]
43
+ Parameters used in the setup measurements step.
44
+ generate_fruit_mask_params : Dict[str, Any]
45
+ Parameters used in the fruit mask generation step.
46
+ enhance_locule_contrast_params : Dict[str, Any]
47
+ Parameters used in the locule contrast enhancement step.
48
+ generate_locule_mask_params : Dict[str, Any]
49
+ Parameters used in the locule mask generation step.
50
+ detect_fruits_params : Dict[str, Any]
51
+ Parameters used in the fruit detection step.
52
+ analyze_morphology_params : Dict[str, Any]
53
+ Parameters used in the morphology analysis step.
54
+ analyze_color_params : Dict[str, Any]
55
+ Parameters used in the color analysis step.
56
+ """
57
+
58
+ # Create a dictionary for each step
59
+ img_params: Dict[str, Any] = field(default_factory=dict)
60
+ setup_measurements_params: Dict[str, Any] = field(default_factory=dict)
61
+ generate_fruit_mask_params: Dict[str, Any] = field(default_factory=dict)
62
+ enhance_locule_contrast_params: Dict[str, Any] = field(default_factory=dict)
63
+ generate_locule_mask_params: Dict[str, Any] = field(default_factory=dict)
64
+ detect_fruits_params: Dict[str, Any] = field(default_factory=dict)
65
+ analyze_morphology_params: Dict[str, Any] = field(default_factory=dict)
66
+ analyze_color_params: Dict[str, Any] = field(default_factory=dict)
67
+
68
+ def to_dict(self) -> Dict[str, Any]:
69
+ """
70
+ Convert dataclass fields to a dictionary.
71
+
72
+ Returns
73
+ -------
74
+ Dict[str, Any]
75
+ Dictionary representation of all analysis parameter fields.
76
+ """
77
+ return asdict(self)
78
+
79
+ def to_formatted_string(self) -> str:
80
+ """
81
+ Return analysis parameters as a readable formatted string.
82
+
83
+ Includes version info, run date, per-step parameters, and
84
+ dependency versions.
85
+
86
+ Returns
87
+ -------
88
+ str
89
+ Formatted string with all processing parameters and metadata.
90
+ """
91
+
92
+ date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
93
+
94
+ lines = [
95
+ "=" * 70,
96
+ "ANALYSIS PROCESSING PARAMETERS",
97
+ "=" * 70,
98
+ f"traitly: v{__version__}",
99
+ f"run date: {date}",
100
+ f"image: {self.img_params.get('img_path', 'not set')}",
101
+ ]
102
+
103
+ sections = {
104
+ "SETUP_MEASUREMENTS": self.setup_measurements_params,
105
+ "GENERATE_FRUIT_MASK": self.generate_fruit_mask_params,
106
+ "ENHANCE_LOCULE_CONTRAST": self.enhance_locule_contrast_params,
107
+ "GENERATE_LOCULE_MASK": self.generate_locule_mask_params,
108
+ "DETECT_FRUITS": self.detect_fruits_params,
109
+ "ANALYZE_MORPHOLOGY": self.analyze_morphology_params,
110
+ "ANALYZE_COLOR": self.analyze_color_params,
111
+ }
112
+ for title, params in sections.items():
113
+ if params:
114
+ lines.append(f"\n{title}:")
115
+ for key, value in params.items():
116
+ lines.append(f" - {key}: {value}")
117
+
118
+ lines.append("\n" + "=" * 70)
119
+
120
+ # Version summary
121
+ lines.append("\nDEPENDENCIES:")
122
+ for pkg, version in self.get_package_versions().items():
123
+ lines.append(f" - {pkg}: {version}")
124
+
125
+ return "\n".join(lines)
126
+
127
+ def save_to_file(self, filepath: str) -> None:
128
+ """
129
+ Save formatted parameters to a plain text file.
130
+
131
+ Parameters
132
+ ----------
133
+ filepath : str
134
+ Destination path for the output .txt file.
135
+
136
+ Raises
137
+ ------
138
+ OSError
139
+ If the file cannot be created or written to ``filepath``.
140
+ """
141
+ with open(filepath, "w", encoding="utf-8") as f:
142
+ f.write(self.to_formatted_string())
143
+
144
+ def save_to_json(self, filepath: str) -> None:
145
+ """
146
+ Save parameters to a JSON file.
147
+
148
+ Parameters
149
+ ----------
150
+ filepath : str
151
+ Destination path for the output .json file.
152
+
153
+ Raises
154
+ ------
155
+ OSError
156
+ If the file cannot be created or written to ``filepath``.
157
+ """
158
+ data = self.to_dict()
159
+
160
+ with open(filepath, "w", encoding="utf-8") as f:
161
+ json.dump(data, f, indent=2, ensure_ascii=False)
162
+
163
+ def get_package_versions(self) -> Dict[str, str]:
164
+ """
165
+ Return installed versions of key dependencies used by traitly.
166
+
167
+ Returns
168
+ -------
169
+ Dict[str, str]
170
+ Dictionary mapping package names to their installed version
171
+ strings. Includes Python version under the key ``'python'``.
172
+ Packages not found are reported as ``'not installed'``.
173
+ """
174
+ packages = [
175
+ "opencv-contrib-python",
176
+ "numpy",
177
+ "pandas",
178
+ "scipy",
179
+ "matplotlib",
180
+ "tqdm",
181
+ "psutil",
182
+ "easyocr",
183
+ "PyMuPDF",
184
+ "ultralytics",
185
+ ]
186
+ versions = {"python": sys.version.split()[0]}
187
+ for pkg in packages:
188
+ try:
189
+ versions[pkg] = importlib.metadata.version(pkg)
190
+ except importlib.metadata.PackageNotFoundError:
191
+ versions[pkg] = "not installed"
192
+ return versions