meabd commited on
Commit
6ba8390
·
verified ·
1 Parent(s): e1c2dc2

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +64 -30
  2. predictor.py +43 -40
app.py CHANGED
@@ -1,10 +1,9 @@
1
  """
2
  app.py
3
  ------
4
- Streamlit front-end for the Crop vs No-Crop SegFormer-B2 demo.
5
-
6
- Includes a diagnostics panel and Advanced controls (inference size,
7
- swap crop/background) to debug an all-background (0% coverage) prediction.
8
  """
9
 
10
  import logging
@@ -28,6 +27,12 @@ st.set_page_config(
28
  layout="wide",
29
  )
30
 
 
 
 
 
 
 
31
 
32
  @st.cache_resource(show_spinner="Loading segmentation model (first run only)…")
33
  def get_segmenter() -> CropSegmenter:
@@ -35,7 +40,6 @@ def get_segmenter() -> CropSegmenter:
35
 
36
 
37
  def render_sidebar() -> dict:
38
- """Render the sidebar and return the selected Advanced options."""
39
  st.sidebar.header("ℹ️ Model Information")
40
  st.sidebar.markdown(
41
  f"""
@@ -46,9 +50,6 @@ def render_sidebar() -> dict:
46
  """
47
  )
48
 
49
- st.sidebar.header("🖼️ Supported Formats")
50
- st.sidebar.markdown("- JPG\n- JPEG\n- PNG")
51
-
52
  st.sidebar.header("🎨 Class Definitions")
53
  st.sidebar.markdown(
54
  """
@@ -62,36 +63,50 @@ def render_sidebar() -> dict:
62
  st.sidebar.header("🛠️ Advanced (debugging)")
63
  sizes = [256, 384, 512, 640]
64
  default_index = sizes.index(INPUT_SIZE) if INPUT_SIZE in sizes else 2
65
- input_size = st.sidebar.selectbox(
66
- "Inference size", sizes, index=default_index,
67
- help="Use the same size you trained at if you know it.",
 
68
  )
69
  swap_classes = st.sidebar.checkbox(
70
- "Swap crop / background",
71
- value=False,
72
- help="Tick this if your crop class was label 0 during training.",
73
  )
74
- return {"input_size": input_size, "swap_classes": swap_classes}
 
 
 
 
75
 
76
 
77
  def render_diagnostics(segmenter: CropSegmenter) -> None:
78
  info = segmenter.load_info
79
  matched, total = info["matched"], info["model_total"]
80
- with st.sidebar.expander("🔍 Weight-loading diagnostics", expanded=(matched < total)):
 
 
 
81
  st.write(f"**Matched:** {matched} / {total} model tensors")
82
  st.write(f"**Checkpoint tensors:** {info['ckpt_total']}")
83
- if matched == total:
 
 
 
 
 
 
 
 
84
  st.success("All weights loaded correctly.")
85
- elif matched == 0:
86
- st.error("No weights matched — wrong checkpoint/architecture.")
87
  else:
88
- st.warning(
89
- f"{total - matched} model tensors did NOT receive weights. "
90
- "If results look wrong, paste the missing keys below to your "
91
- "developer."
92
- )
93
- if info["missing"]:
94
- st.code("\n".join(info["missing"][:20]))
 
95
 
96
 
97
  def main() -> None:
@@ -139,6 +154,7 @@ def main() -> None:
139
  image,
140
  input_size=options["input_size"],
141
  swap_classes=options["swap_classes"],
 
142
  )
143
  color_mask = utils.create_color_mask(mask)
144
  overlay = utils.create_overlay(image, mask)
@@ -161,11 +177,29 @@ def main() -> None:
161
 
162
  crop_ratio = float((mask == 1).mean()) * 100.0
163
  st.metric("Estimated crop coverage", f"{crop_ratio:.1f}%")
164
- if crop_ratio == 0.0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  st.warning(
166
- "0% crop detected. Open '🔍 Weight-loading diagnostics' in the "
167
- "sidebar to check the weights loaded, and try the 'Swap crop / "
168
- "background' toggle or a different inference size."
169
  )
170
 
171
  png_bytes = utils.image_to_png_bytes(combined)
 
1
  """
2
  app.py
3
  ------
4
+ Streamlit front-end for the Crop vs No-Crop SegFormer-B2 demo, with a
5
+ detailed diagnostics panel and Advanced controls (inference size, normalization,
6
+ swap crop/background) to debug an all-one-class prediction.
 
7
  """
8
 
9
  import logging
 
27
  layout="wide",
28
  )
29
 
30
+ NORMALIZATIONS = {
31
+ "ImageNet (mean/std)": "imagenet",
32
+ "Simple 0–1 (just /255)": "zero_one",
33
+ "-1 to 1": "minus_one_one",
34
+ }
35
+
36
 
37
  @st.cache_resource(show_spinner="Loading segmentation model (first run only)…")
38
  def get_segmenter() -> CropSegmenter:
 
40
 
41
 
42
  def render_sidebar() -> dict:
 
43
  st.sidebar.header("ℹ️ Model Information")
44
  st.sidebar.markdown(
45
  f"""
 
50
  """
51
  )
52
 
 
 
 
53
  st.sidebar.header("🎨 Class Definitions")
54
  st.sidebar.markdown(
55
  """
 
63
  st.sidebar.header("🛠️ Advanced (debugging)")
64
  sizes = [256, 384, 512, 640]
65
  default_index = sizes.index(INPUT_SIZE) if INPUT_SIZE in sizes else 2
66
+ input_size = st.sidebar.selectbox("Inference size", sizes, index=default_index)
67
+ norm_label = st.sidebar.selectbox(
68
+ "Normalization", list(NORMALIZATIONS.keys()), index=0,
69
+ help="Must match how images were normalized during training.",
70
  )
71
  swap_classes = st.sidebar.checkbox(
72
+ "Swap crop / background", value=False,
73
+ help="Tick if your crop class was label 0 during training.",
 
74
  )
75
+ return {
76
+ "input_size": input_size,
77
+ "normalization": NORMALIZATIONS[norm_label],
78
+ "swap_classes": swap_classes,
79
+ }
80
 
81
 
82
  def render_diagnostics(segmenter: CropSegmenter) -> None:
83
  info = segmenter.load_info
84
  matched, total = info["matched"], info["model_total"]
85
+ head_ok = info["classifier_loaded"]
86
+ needs_attention = (matched < total) or (not head_ok)
87
+
88
+ with st.sidebar.expander("🔍 Weight-loading diagnostics", expanded=needs_attention):
89
  st.write(f"**Matched:** {matched} / {total} model tensors")
90
  st.write(f"**Checkpoint tensors:** {info['ckpt_total']}")
91
+ st.write(f"**Classifier head loaded:** {'✅ yes' if head_ok else '❌ NO'}")
92
+
93
+ if not head_ok:
94
+ st.error(
95
+ "The segmentation head did NOT load — this is why every pixel "
96
+ "comes out the same class. Copy the two lists below and send "
97
+ "them to your developer."
98
+ )
99
+ elif matched == total:
100
  st.success("All weights loaded correctly.")
 
 
101
  else:
102
+ st.warning(f"{total - matched} model tensors did not get weights.")
103
+
104
+ if info["missing"]:
105
+ st.caption("Model layers still MISSING weights:")
106
+ st.code("\n".join(info["missing"][:30]))
107
+ if info["unexpected"]:
108
+ st.caption("Checkpoint layers NOT used (unexpected names):")
109
+ st.code("\n".join(info["unexpected"][:30]))
110
 
111
 
112
  def main() -> None:
 
154
  image,
155
  input_size=options["input_size"],
156
  swap_classes=options["swap_classes"],
157
+ normalization=options["normalization"],
158
  )
159
  color_mask = utils.create_color_mask(mask)
160
  overlay = utils.create_overlay(image, mask)
 
177
 
178
  crop_ratio = float((mask == 1).mean()) * 100.0
179
  st.metric("Estimated crop coverage", f"{crop_ratio:.1f}%")
180
+
181
+ # Show the raw model behaviour — confirms whether it is discriminating.
182
+ summary = segmenter.last_logit_summary
183
+ if summary:
184
+ with st.expander("📈 Model output details (for debugging)"):
185
+ st.write(
186
+ f"Pixels predicted as crop (before swap): "
187
+ f"{summary['frac_pixels_pred_crop'] * 100:.1f}%"
188
+ )
189
+ st.write(
190
+ f"Mean logit gap (crop − background): "
191
+ f"{summary['mean_gap_crop_minus_bg']:.3f} "
192
+ f"(positive favors crop, negative favors background)"
193
+ )
194
+ st.write(
195
+ f"Logit range: {summary['logit_min']:.2f} … {summary['logit_max']:.2f}"
196
+ )
197
+
198
+ if crop_ratio in (0.0, 100.0):
199
  st.warning(
200
+ "Every pixel got the same class. Open '🔍 Weight-loading "
201
+ "diagnostics' to check the classifier head loaded, and try a "
202
+ "different **Normalization** in the sidebar."
203
  )
204
 
205
  png_bytes = utils.image_to_png_bytes(combined)
predictor.py CHANGED
@@ -6,12 +6,10 @@ Model loading and inference for the Crop vs No-Crop SegFormer-B2 app.
6
  Architecture is rebuilt from the `nvidia/mit-b2` backbone config with
7
  num_labels=2, then the fine-tuned `best.pt` state dict is loaded on top.
8
 
9
- This version includes a ROBUST weight loader that:
10
- * auto-detects common key-naming differences (module./model./segformer.)
11
- * matches tensors by name AND shape
12
- * reports exactly how many weights matched (self.load_info)
13
-
14
- so an all-background prediction (0% coverage) can be diagnosed quickly.
15
  """
16
 
17
  from __future__ import annotations
@@ -49,17 +47,12 @@ class InferenceError(Exception):
49
  """Raised when inference fails for a given image."""
50
 
51
 
52
- # --------------------------------------------------------------------------- #
53
- # Weights location
54
- # --------------------------------------------------------------------------- #
55
  def _resolve_weights_path() -> str:
56
  """Return a local path to best.pt (download from HF Hub if HF_REPO_ID set)."""
57
  if HF_REPO_ID:
58
  try:
59
  from huggingface_hub import hf_hub_download
60
 
61
- logger.info("Downloading '%s' from HF repo '%s'",
62
- WEIGHTS_FILENAME, HF_REPO_ID)
63
  return hf_hub_download(
64
  repo_id=HF_REPO_ID, filename=WEIGHTS_FILENAME, token=HF_TOKEN
65
  )
@@ -79,11 +72,7 @@ def _resolve_weights_path() -> str:
79
  )
80
 
81
 
82
- # --------------------------------------------------------------------------- #
83
- # Robust state-dict alignment
84
- # --------------------------------------------------------------------------- #
85
  def _extract_state_dict(checkpoint):
86
- """Pull the actual weight dict out of common checkpoint wrappers."""
87
  if isinstance(checkpoint, dict):
88
  for key in ("state_dict", "model_state_dict", "model"):
89
  inner = checkpoint.get(key)
@@ -93,7 +82,6 @@ def _extract_state_dict(checkpoint):
93
 
94
 
95
  def _strip_module(state_dict):
96
- """Remove repeated 'module.' prefixes (from DataParallel/DDP checkpoints)."""
97
  cleaned = {}
98
  for key, value in state_dict.items():
99
  new_key = key
@@ -105,14 +93,13 @@ def _strip_module(state_dict):
105
 
106
  def _align_state_dict(ckpt_sd, model_sd):
107
  """
108
- Try a few key transforms and keep the subset of checkpoint tensors that
109
- match the model by name AND shape. Returns (aligned_dict, info).
 
 
110
  """
111
  ckpt_sd = _strip_module(ckpt_sd)
112
 
113
- # Each transform is tried per-key (not globally), so checkpoints that mix
114
- # prefixes — e.g. backbone keys needing a 'segformer.' prefix while head
115
- # keys are already correct — still align fully.
116
  transforms_to_try = [
117
  lambda k: k, # as-is
118
  lambda k: "segformer." + k, # add prefix
@@ -122,6 +109,7 @@ def _align_state_dict(ckpt_sd, model_sd):
122
  ]
123
 
124
  best = {}
 
125
  for key, value in ckpt_sd.items():
126
  for transform in transforms_to_try:
127
  new_key = transform(key)
@@ -132,21 +120,25 @@ def _align_state_dict(ckpt_sd, model_sd):
132
  and tuple(model_sd[new_key].shape) == tuple(value.shape)
133
  ):
134
  best[new_key] = value
 
135
  break
136
 
137
  missing = sorted(set(model_sd) - set(best))
 
 
 
 
138
  info = {
139
  "model_total": len(model_sd),
140
  "ckpt_total": len(ckpt_sd),
141
  "matched": len(best),
142
  "missing": missing,
 
 
143
  }
144
  return best, info
145
 
146
 
147
- # --------------------------------------------------------------------------- #
148
- # Segmenter
149
- # --------------------------------------------------------------------------- #
150
  class CropSegmenter:
151
  """Loads the fine-tuned SegFormer-B2 model and performs inference."""
152
 
@@ -155,8 +147,9 @@ class CropSegmenter:
155
  "cuda" if torch.cuda.is_available() else "cpu"
156
  )
157
  logger.info("Using device: %s", self.device)
 
158
 
159
- # Build architecture from config (small download; weights come from best.pt)
160
  try:
161
  seg_config = SegformerConfig.from_pretrained(
162
  MODEL_NAME, num_labels=NUM_LABELS
@@ -172,7 +165,6 @@ class CropSegmenter:
172
  f"Could not build SegFormer from '{MODEL_NAME}': {exc2}"
173
  ) from exc
174
 
175
- # Read the checkpoint (handle both new weights_only default and old files)
176
  weights_path = _resolve_weights_path()
177
  try:
178
  try:
@@ -193,34 +185,42 @@ class CropSegmenter:
193
  self.model.load_state_dict(aligned, strict=False)
194
 
195
  logger.info(
196
- "Weights matched %d/%d model tensors (checkpoint had %d).",
197
  self.load_info["matched"],
198
  self.load_info["model_total"],
199
  self.load_info["ckpt_total"],
 
200
  )
201
 
202
  if self.load_info["matched"] == 0:
203
  raise ModelLoadError(
204
  "None of the weights in best.pt matched the SegFormer-B2 "
205
- "architecture. The checkpoint may be from a different model."
206
  )
207
 
208
  self.model.to(self.device)
209
  self.model.eval()
210
 
 
 
 
 
 
 
 
 
 
211
  @torch.no_grad()
212
  def predict(
213
  self,
214
  image: Image.Image,
215
  input_size: int | None = None,
216
  swap_classes: bool = False,
 
217
  ) -> np.ndarray:
218
  """
219
  Run segmentation. Returns a uint8 mask (H, W) in {0, 1} at the original
220
- image resolution.
221
-
222
- input_size: square size used for inference (default INPUT_SIZE).
223
- swap_classes: flip class 0<->1 (use if crop was label 0 in training).
224
  """
225
  try:
226
  size = int(input_size or INPUT_SIZE)
@@ -228,13 +228,7 @@ class CropSegmenter:
228
  image = image.convert("RGB")
229
  orig_w, orig_h = image.size
230
 
231
- preprocess = transforms.Compose(
232
- [
233
- transforms.Resize((size, size)),
234
- transforms.ToTensor(),
235
- transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
236
- ]
237
- )
238
  tensor = preprocess(image).unsqueeze(0).to(self.device)
239
 
240
  logits = self.model(pixel_values=tensor).logits # (1, 2, h/4, w/4)
@@ -242,8 +236,17 @@ class CropSegmenter:
242
  logits, size=(orig_h, orig_w),
243
  mode="bilinear", align_corners=False,
244
  )
245
- mask = upsampled.argmax(dim=1).squeeze(0).to("cpu").numpy().astype(np.uint8)
246
 
 
 
 
 
 
 
 
 
 
 
247
  if swap_classes:
248
  mask = (1 - mask).astype(np.uint8)
249
  return mask
 
6
  Architecture is rebuilt from the `nvidia/mit-b2` backbone config with
7
  num_labels=2, then the fine-tuned `best.pt` state dict is loaded on top.
8
 
9
+ Robust loader: auto-detects common key-naming differences, matches tensors by
10
+ name AND shape, and reports detailed diagnostics (including whether the
11
+ segmentation HEAD/classifier loaded) so an all-one-class prediction can be
12
+ debugged precisely.
 
 
13
  """
14
 
15
  from __future__ import annotations
 
47
  """Raised when inference fails for a given image."""
48
 
49
 
 
 
 
50
  def _resolve_weights_path() -> str:
51
  """Return a local path to best.pt (download from HF Hub if HF_REPO_ID set)."""
52
  if HF_REPO_ID:
53
  try:
54
  from huggingface_hub import hf_hub_download
55
 
 
 
56
  return hf_hub_download(
57
  repo_id=HF_REPO_ID, filename=WEIGHTS_FILENAME, token=HF_TOKEN
58
  )
 
72
  )
73
 
74
 
 
 
 
75
  def _extract_state_dict(checkpoint):
 
76
  if isinstance(checkpoint, dict):
77
  for key in ("state_dict", "model_state_dict", "model"):
78
  inner = checkpoint.get(key)
 
82
 
83
 
84
  def _strip_module(state_dict):
 
85
  cleaned = {}
86
  for key, value in state_dict.items():
87
  new_key = key
 
93
 
94
  def _align_state_dict(ckpt_sd, model_sd):
95
  """
96
+ Match checkpoint tensors to model tensors by name (with a few common
97
+ transforms) AND shape. Returns (aligned_dict, info) where info reports
98
+ matched count, the model layers still MISSING weights, the checkpoint
99
+ layers that were UNUSED, and whether the classifier head loaded.
100
  """
101
  ckpt_sd = _strip_module(ckpt_sd)
102
 
 
 
 
103
  transforms_to_try = [
104
  lambda k: k, # as-is
105
  lambda k: "segformer." + k, # add prefix
 
109
  ]
110
 
111
  best = {}
112
+ used_ckpt_keys = set()
113
  for key, value in ckpt_sd.items():
114
  for transform in transforms_to_try:
115
  new_key = transform(key)
 
120
  and tuple(model_sd[new_key].shape) == tuple(value.shape)
121
  ):
122
  best[new_key] = value
123
+ used_ckpt_keys.add(key)
124
  break
125
 
126
  missing = sorted(set(model_sd) - set(best))
127
+ unexpected = sorted(set(ckpt_sd) - used_ckpt_keys)
128
+ # The decode-head classifier is what turns features into crop/background.
129
+ classifier_loaded = any("classifier" in k for k in best)
130
+
131
  info = {
132
  "model_total": len(model_sd),
133
  "ckpt_total": len(ckpt_sd),
134
  "matched": len(best),
135
  "missing": missing,
136
+ "unexpected": unexpected,
137
+ "classifier_loaded": classifier_loaded,
138
  }
139
  return best, info
140
 
141
 
 
 
 
142
  class CropSegmenter:
143
  """Loads the fine-tuned SegFormer-B2 model and performs inference."""
144
 
 
147
  "cuda" if torch.cuda.is_available() else "cpu"
148
  )
149
  logger.info("Using device: %s", self.device)
150
+ self.last_logit_summary: dict | None = None
151
 
152
+ # Build architecture from config (weights come from best.pt)
153
  try:
154
  seg_config = SegformerConfig.from_pretrained(
155
  MODEL_NAME, num_labels=NUM_LABELS
 
165
  f"Could not build SegFormer from '{MODEL_NAME}': {exc2}"
166
  ) from exc
167
 
 
168
  weights_path = _resolve_weights_path()
169
  try:
170
  try:
 
185
  self.model.load_state_dict(aligned, strict=False)
186
 
187
  logger.info(
188
+ "Weights matched %d/%d (ckpt had %d). classifier_loaded=%s",
189
  self.load_info["matched"],
190
  self.load_info["model_total"],
191
  self.load_info["ckpt_total"],
192
+ self.load_info["classifier_loaded"],
193
  )
194
 
195
  if self.load_info["matched"] == 0:
196
  raise ModelLoadError(
197
  "None of the weights in best.pt matched the SegFormer-B2 "
198
+ "architecture. The checkpoint is likely from a different model."
199
  )
200
 
201
  self.model.to(self.device)
202
  self.model.eval()
203
 
204
+ def _build_preprocess(self, size: int, normalization: str):
205
+ steps = [transforms.Resize((size, size)), transforms.ToTensor()]
206
+ if normalization == "imagenet":
207
+ steps.append(transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD))
208
+ elif normalization == "minus_one_one":
209
+ steps.append(transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)))
210
+ # "zero_one" -> ToTensor only (pixels in [0, 1])
211
+ return transforms.Compose(steps)
212
+
213
  @torch.no_grad()
214
  def predict(
215
  self,
216
  image: Image.Image,
217
  input_size: int | None = None,
218
  swap_classes: bool = False,
219
+ normalization: str = "imagenet",
220
  ) -> np.ndarray:
221
  """
222
  Run segmentation. Returns a uint8 mask (H, W) in {0, 1} at the original
223
+ resolution. Also stores per-call logit stats in self.last_logit_summary.
 
 
 
224
  """
225
  try:
226
  size = int(input_size or INPUT_SIZE)
 
228
  image = image.convert("RGB")
229
  orig_w, orig_h = image.size
230
 
231
+ preprocess = self._build_preprocess(size, normalization)
 
 
 
 
 
 
232
  tensor = preprocess(image).unsqueeze(0).to(self.device)
233
 
234
  logits = self.model(pixel_values=tensor).logits # (1, 2, h/4, w/4)
 
236
  logits, size=(orig_h, orig_w),
237
  mode="bilinear", align_corners=False,
238
  )
 
239
 
240
+ class0 = upsampled[:, 0]
241
+ class1 = upsampled[:, 1]
242
+ self.last_logit_summary = {
243
+ "frac_pixels_pred_crop": float((class1 > class0).float().mean()),
244
+ "mean_gap_crop_minus_bg": float((class1 - class0).mean().item()),
245
+ "logit_min": float(upsampled.min().item()),
246
+ "logit_max": float(upsampled.max().item()),
247
+ }
248
+
249
+ mask = upsampled.argmax(dim=1).squeeze(0).to("cpu").numpy().astype(np.uint8)
250
  if swap_classes:
251
  mask = (1 - mask).astype(np.uint8)
252
  return mask