cisdeli commited on
Commit
044af6e
·
1 Parent(s): 3ecb175

feat: gradio demo

Browse files
Files changed (3) hide show
  1. app/app.py +295 -0
  2. app/m3_coefficients.json +55 -0
  3. app/requirements.txt +6 -0
app/app.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio demo: pick or upload a tuber image and get morphometrics plus predicted plot yield.
2
+
3
+ Pipeline per image:
4
+ 1. YOLOv11-seg instance segmentation.
5
+ 2. Per-tuber polygon morphometrics.
6
+ 3. Pixel-to-inch calibration via the 30" conveyor belt.
7
+ 4. USDA size class assignment.
8
+ 5. Plot-level features (total and class areas, mean_area) feed an M3
9
+ yield prediction using saved coefficients.
10
+
11
+ Two input modes:
12
+ - Dataset image: pick from the bundled set under $POTATOPHENO_DATASET.
13
+ - Custom upload: any top-down RGB image of tubers on a 30" conveyor.
14
+
15
+ Environment variables:
16
+ POTATOPHENO_WEIGHTS path to bestModel.pt (default: app/bestModel.pt)
17
+ POTATOPHENO_COEFS path to m3_coefficients.json (default: app/m3_coefficients.json)
18
+ POTATOPHENO_DATASET root with 2025<TRIAL>/ subdirs (default: app/dataset)
19
+ POTATOPHENO_BELT_IN conveyor belt width in inches (default: 30.0)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ from pathlib import Path
27
+
28
+ import gradio as gr
29
+ import numpy as np
30
+ import pandas as pd
31
+ from PIL import Image, ImageDraw
32
+
33
+ from potatopheno.calibration import BELT_WIDTH_IN_DEFAULT, apply_real_scale
34
+ from potatopheno.classification import assign_usda_class
35
+ from potatopheno.io import IMG_EXTS
36
+ from potatopheno.morphometrics import polygon_shape_metrics
37
+ from potatopheno.segmentation import load_model, predict_image
38
+
39
+ APP_DIR = Path(__file__).parent
40
+
41
+ WEIGHTS_PATH = Path(os.environ.get("POTATOPHENO_WEIGHTS", APP_DIR / "bestModel.pt"))
42
+ COEFS_PATH = Path(os.environ.get("POTATOPHENO_COEFS", APP_DIR / "m3_coefficients.json"))
43
+ DATASET_ROOT = Path(os.environ.get("POTATOPHENO_DATASET", APP_DIR / "dataset"))
44
+ BELT_WIDTH_IN = float(os.environ.get("POTATOPHENO_BELT_IN", BELT_WIDTH_IN_DEFAULT))
45
+
46
+ # USDA-class colour palette (B -> OV; small -> large). Used both for the overlay
47
+ # and the legend rendered next to the headline.
48
+ USDA_COLORS: dict[str, tuple[int, int, int]] = {
49
+ "B": (66, 135, 245),
50
+ "A1": (52, 168, 83),
51
+ "A2": (251, 188, 5),
52
+ "OV": (234, 67, 53),
53
+ }
54
+ UNKNOWN_COLOR = (180, 180, 180)
55
+
56
+ print(f"[app] weights: {WEIGHTS_PATH}")
57
+ print(f"[app] coefficients: {COEFS_PATH}")
58
+ print(f"[app] dataset: {DATASET_ROOT}")
59
+ _model = load_model(WEIGHTS_PATH)
60
+ _coefs = json.loads(Path(COEFS_PATH).read_text())["M3"]
61
+
62
+
63
+ def _scan_dataset(root: Path) -> dict[str, str]:
64
+ """Return {label: absolute_path} for every image under root/2025<TRIAL>/."""
65
+ out: dict[str, str] = {}
66
+ if not root.exists():
67
+ print(f"[app] WARNING: dataset root {root} does not exist; dataset tab will be empty")
68
+ return out
69
+ for trial_dir in sorted(root.glob("2025*")):
70
+ trial = trial_dir.name.removeprefix("2025")
71
+ for img in sorted(trial_dir.rglob("*")):
72
+ if img.suffix.lower() in IMG_EXTS:
73
+ out[f"{trial} / {img.stem}"] = str(img)
74
+ return out
75
+
76
+
77
+ _dataset = _scan_dataset(DATASET_ROOT)
78
+ _dataset_labels = list(_dataset.keys())
79
+ print(f"[app] dataset images indexed: {len(_dataset_labels)}")
80
+
81
+
82
+ # ----- core analysis (shared by both input modes)
83
+ def _render_overlay(
84
+ image: Image.Image,
85
+ detections: list[tuple[int, np.ndarray]],
86
+ usda_classes: list[str | float],
87
+ ) -> Image.Image:
88
+ """Draw per-tuber polygons colored by predicted USDA size class."""
89
+ base = image.convert("RGBA")
90
+ layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
91
+ draw = ImageDraw.Draw(layer)
92
+ w, h = base.size
93
+ for (_class_id, poly_norm), usda in zip(detections, usda_classes):
94
+ coords = [(float(x) * w, float(y) * h) for x, y in poly_norm]
95
+ if len(coords) < 3:
96
+ continue
97
+ color = USDA_COLORS.get(str(usda), UNKNOWN_COLOR)
98
+ draw.polygon(coords, outline=(*color, 235), fill=(*color, 95))
99
+ return Image.alpha_composite(base, layer).convert("RGB")
100
+
101
+
102
+ def _per_tuber_dataframe(detections: list[tuple[int, np.ndarray]], img_w: int, img_h: int) -> pd.DataFrame:
103
+ rows: list[dict] = []
104
+ for class_id, poly_norm in detections:
105
+ xs = poly_norm[:, 0] * img_w
106
+ ys = poly_norm[:, 1] * img_h
107
+ metrics = polygon_shape_metrics(xs, ys)
108
+ rows.append({"class_id": class_id, "img_width_px": img_w, "img_height_px": img_h, **metrics})
109
+ if not rows:
110
+ return pd.DataFrame()
111
+ df = pd.DataFrame(rows)
112
+ df = apply_real_scale(df, belt_width_in=BELT_WIDTH_IN)
113
+ df = assign_usda_class(df)
114
+ return df
115
+
116
+
117
+ def _aggregate_features(df: pd.DataFrame) -> dict[str, float]:
118
+ by_class = df.groupby("class", observed=True)["area_poly_in2"].sum().to_dict()
119
+ n_tubers = int(len(df))
120
+ total = float(df["area_poly_in2"].sum())
121
+ return {
122
+ "area_B_in2": float(by_class.get("B", 0.0)),
123
+ "area_A1_in2": float(by_class.get("A1", 0.0)),
124
+ "area_A2_in2": float(by_class.get("A2", 0.0)),
125
+ "area_OV_in2": float(by_class.get("OV", 0.0)),
126
+ "total_area_in2": total,
127
+ "mean_area": total / n_tubers if n_tubers else 0.0,
128
+ "n_tubers": n_tubers,
129
+ }
130
+
131
+
132
+ def _predict_m3(features: dict[str, float]) -> float:
133
+ yhat = _coefs["intercept"]
134
+ for predictor, coef in _coefs["coefficients"].items():
135
+ yhat += coef * features[predictor]
136
+ return float(yhat)
137
+
138
+
139
+ def _class_summary_df(df: pd.DataFrame, feats: dict[str, float]) -> pd.DataFrame:
140
+ counts = df["class"].value_counts().reindex(["B", "A1", "A2", "OV"], fill_value=0)
141
+ n_total = int(counts.sum())
142
+ rows = []
143
+ for cls in ["B", "A1", "A2", "OV"]:
144
+ n = int(counts[cls])
145
+ area = feats[f"area_{cls}_in2"]
146
+ share = (n / n_total * 100.0) if n_total else 0.0
147
+ rows.append({"class": cls, "tubers": n, "share (%)": round(share, 1), "area (in²)": round(area, 2)})
148
+ return pd.DataFrame(rows)
149
+
150
+
151
+ def _empty_analysis(message: str):
152
+ return None, message, pd.DataFrame(), pd.DataFrame()
153
+
154
+
155
+ def _analyze_pil(image: Image.Image, source_label: str | None = None):
156
+ """Shared analysis path for both dataset and upload tabs.
157
+
158
+ Returns (overlay PIL image, headline markdown, class summary df, per-tuber df).
159
+ """
160
+ detections = predict_image(_model, image, retina_masks=True)
161
+ df = _per_tuber_dataframe(detections, image.width, image.height)
162
+
163
+ if df.empty:
164
+ return _empty_analysis("**No tubers detected.**")
165
+
166
+ overlay = _render_overlay(image, detections, df["class"].astype(str).tolist())
167
+ feats = _aggregate_features(df)
168
+ yhat = _predict_m3(feats)
169
+ class_summary = _class_summary_df(df, feats)
170
+
171
+ source_line = f"_Source: {source_label}_\n\n" if source_label else ""
172
+ headline = (
173
+ f"## Predicted yield (M3): **{yhat:.2f} kg**\n\n"
174
+ f"{source_line}"
175
+ f"- {feats['n_tubers']} tubers detected\n"
176
+ f"- Total projected area: {feats['total_area_in2']:.2f} in²\n"
177
+ f"- Mean tuber area: {feats['mean_area']:.2f} in²\n\n"
178
+ f"**Mask color legend**: "
179
+ f"<span style='color:rgb(66,135,245)'>■ B</span> "
180
+ f"<span style='color:rgb(52,168,83)'>■ A1</span> "
181
+ f"<span style='color:rgb(251,188,5)'>■ A2</span> "
182
+ f"<span style='color:rgb(234,67,53)'>■ OV</span>"
183
+ )
184
+
185
+ display_cols = [
186
+ "class", "area_poly_in2", "width_in", "height_in",
187
+ "inMinDim", "perimeter_px", "circularity", "eccentricity",
188
+ ]
189
+ return overlay, headline, class_summary, df[display_cols].round(3)
190
+
191
+
192
+ # ----- handlers
193
+ def _load_dataset_preview(label: str | None):
194
+ if not label or label not in _dataset:
195
+ return None
196
+ return Image.open(_dataset[label])
197
+
198
+
199
+ def analyze_from_dataset(label: str | None):
200
+ if not label or label not in _dataset:
201
+ return _empty_analysis("Pick a dataset image from the dropdown.")
202
+ image = Image.open(_dataset[label])
203
+ return _analyze_pil(image, source_label=label)
204
+
205
+
206
+ def analyze_from_upload(image: Image.Image | None):
207
+ if image is None:
208
+ return _empty_analysis("Upload an image to analyze.")
209
+ return _analyze_pil(image, source_label=None)
210
+
211
+
212
+ # ----- UI
213
+ def _results_section() -> tuple[gr.Markdown, gr.Dataframe, gr.Dataframe]:
214
+ """Full-width Results block: headline | class breakdown side-by-side, then detail full-width."""
215
+ gr.Markdown("## Results")
216
+ with gr.Row(equal_height=False):
217
+ with gr.Column(scale=1):
218
+ headline = gr.Markdown()
219
+ with gr.Column(scale=1):
220
+ class_summary = gr.Dataframe(
221
+ label="Per class info",
222
+ headers=["class", "tubers", "share (%)", "area (in²)"],
223
+ interactive=False,
224
+ wrap=True,
225
+ )
226
+ detail = gr.Dataframe(label="Per-tuber morphometrics", interactive=False, wrap=True)
227
+ return headline, class_summary, detail
228
+
229
+
230
+ with gr.Blocks(title="Potato tuber phenotyping demo") as demo:
231
+ gr.Markdown(
232
+ "# Potato tuber phenotyping demo\n"
233
+ "Pipeline: YOLOv11-seg instance segmentation → per-tuber morphometrics → USDA size class"
234
+ " → plot-yield prediction via the M3 model detailed in the paper."
235
+ "<br />"
236
+ "***Images are assumed to be top-down RGB shots of harvested tubers on a 30″ wide conveyor belt.***"
237
+ )
238
+
239
+ with gr.Tabs():
240
+ with gr.Tab("Dataset image"):
241
+ gr.Markdown(
242
+ f"Pick one of **{len(_dataset_labels)}** images from the paper dataset. "
243
+ "Start typing to filter."
244
+ )
245
+ with gr.Row(equal_height=False):
246
+ with gr.Column(scale=1):
247
+ dataset_selector = gr.Dropdown(
248
+ choices=_dataset_labels,
249
+ label="Dataset image",
250
+ value=_dataset_labels[0] if _dataset_labels else None,
251
+ filterable=True,
252
+ )
253
+ dataset_preview = gr.Image(type="pil", label="Selected", interactive=False, height=320)
254
+ dataset_btn = gr.Button("Analyze", variant="primary")
255
+ with gr.Column(scale=2):
256
+ ds_overlay = gr.Image(label="Detected tubers (coloured by USDA size class)", height=480)
257
+
258
+ ds_headline, ds_class, ds_detail = _results_section()
259
+
260
+ dataset_selector.change(
261
+ fn=_load_dataset_preview,
262
+ inputs=[dataset_selector],
263
+ outputs=[dataset_preview],
264
+ )
265
+ dataset_btn.click(
266
+ fn=analyze_from_dataset,
267
+ inputs=[dataset_selector],
268
+ outputs=[ds_overlay, ds_headline, ds_class, ds_detail],
269
+ )
270
+ if _dataset_labels:
271
+ demo.load(fn=_load_dataset_preview, inputs=[dataset_selector], outputs=[dataset_preview])
272
+
273
+ with gr.Tab("Upload your own"):
274
+ gr.Markdown(
275
+ "Drop in a top-down RGB image of tubers on a 30″ conveyor. "
276
+ "Calibration assumes the 30″ width maps to the image height in pixels."
277
+ )
278
+ with gr.Row(equal_height=False):
279
+ with gr.Column(scale=1):
280
+ upload_image = gr.Image(type="pil", label="Image", height=320)
281
+ upload_btn = gr.Button("Analyze", variant="primary")
282
+ with gr.Column(scale=2):
283
+ up_overlay = gr.Image(label="Detected tubers (coloured by USDA size class)", height=480)
284
+
285
+ up_headline, up_class, up_detail = _results_section()
286
+
287
+ upload_btn.click(
288
+ fn=analyze_from_upload,
289
+ inputs=[upload_image],
290
+ outputs=[up_overlay, up_headline, up_class, up_detail],
291
+ )
292
+
293
+
294
+ if __name__ == "__main__":
295
+ demo.launch()
app/m3_coefficients.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "M1": {
3
+ "name": "M1",
4
+ "predictors": [
5
+ "total_area_in2"
6
+ ],
7
+ "response": "obs_sum_B_A1_A2_OV",
8
+ "intercept": -1.7049879158504684,
9
+ "coefficients": {
10
+ "total_area_in2": 0.02647780815837893
11
+ },
12
+ "n_train_plots": 354,
13
+ "in_sample_r2": 0.9273415624231656
14
+ },
15
+ "M2": {
16
+ "name": "M2",
17
+ "predictors": [
18
+ "area_B_in2",
19
+ "area_A1_in2",
20
+ "area_A2_in2",
21
+ "area_OV_in2"
22
+ ],
23
+ "response": "obs_sum_B_A1_A2_OV",
24
+ "intercept": -0.8563684810328649,
25
+ "coefficients": {
26
+ "area_B_in2": 0.014636824171552563,
27
+ "area_A1_in2": 0.024519069655545584,
28
+ "area_A2_in2": 0.031892946157654006,
29
+ "area_OV_in2": 0.030907805108839847
30
+ },
31
+ "n_train_plots": 354,
32
+ "in_sample_r2": 0.9732369058081672
33
+ },
34
+ "M3": {
35
+ "name": "M3",
36
+ "predictors": [
37
+ "area_B_in2",
38
+ "area_A1_in2",
39
+ "area_A2_in2",
40
+ "area_OV_in2",
41
+ "mean_area"
42
+ ],
43
+ "response": "obs_sum_B_A1_A2_OV",
44
+ "intercept": 0.4692250209658635,
45
+ "coefficients": {
46
+ "area_B_in2": 0.012727900951238946,
47
+ "area_A1_in2": 0.02460340123486521,
48
+ "area_A2_in2": 0.03252499279274441,
49
+ "area_OV_in2": 0.03344203897991628,
50
+ "mean_area": -0.29153350745765855
51
+ },
52
+ "n_train_plots": 354,
53
+ "in_sample_r2": 0.9735906422132543
54
+ }
55
+ }
app/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ numpy>=1.26
2
+ pandas>=2.2
3
+ pillow>=10.0
4
+ scikit-learn>=1.5
5
+ ultralytics>=8.3
6
+ gradio>=4.0