Spaces:
Sleeping
Sleeping
Upload basic_explainer.py
Browse files
src/explainers/basic_explainer.py
CHANGED
|
@@ -128,14 +128,32 @@ class BasicExplainer:
|
|
| 128 |
supports_fake += 1
|
| 129 |
|
| 130 |
conflict = (supports_fake > 0 and supports_real > 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
# -------------------- Triage decision (narrative only) --------------------
|
| 133 |
triage_label = base_label_str
|
| 134 |
if self.enable_triage and conflict and confidence < self.triage_conf_threshold:
|
| 135 |
triage_label = "UNCERTAIN"
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
# Intro sentence
|
| 138 |
-
if triage_label == "UNCERTAIN":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
explanation_parts.append(
|
| 140 |
f"The detector predicts this image is **{base_label_str}** "
|
| 141 |
f"with {confidence_str} confidence ({confidence:.2f}), "
|
|
@@ -170,12 +188,12 @@ class BasicExplainer:
|
|
| 170 |
if prediction_label == 0:
|
| 171 |
explanation_parts.append(
|
| 172 |
f"- **Noiseprint**: fingerprint lies within the range seen in training real images "
|
| 173 |
-
f"(mismatch={nm:.2f}
|
| 174 |
)
|
| 175 |
else:
|
| 176 |
explanation_parts.append(
|
| 177 |
f"- **Noiseprint**: fingerprint lies within the range seen in training real images "
|
| 178 |
-
f"(mismatch={nm:.2f}
|
| 179 |
)
|
| 180 |
cues_used += 1
|
| 181 |
|
|
@@ -225,25 +243,59 @@ class BasicExplainer:
|
|
| 225 |
f"and conflicts with the REAL prediction."
|
| 226 |
)
|
| 227 |
cues_used += 1
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
| 235 |
|
| 236 |
-
# -------------------- Data-driven drivers (
|
| 237 |
if contributions:
|
| 238 |
sorted_contribs = sorted(contributions.items(), key=lambda x: abs(x[1]), reverse=True)
|
| 239 |
-
top
|
| 240 |
-
|
| 241 |
-
|
|
|
|
| 242 |
|
| 243 |
if pos:
|
| 244 |
-
explanation_parts.append(f"
|
|
|
|
|
|
|
|
|
|
| 245 |
if neg:
|
| 246 |
-
explanation_parts.append(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
# In high-conflict cases, add a final triage note
|
| 249 |
if triage_label == "UNCERTAIN" and not is_ood:
|
|
|
|
| 128 |
supports_fake += 1
|
| 129 |
|
| 130 |
conflict = (supports_fake > 0 and supports_real > 0)
|
| 131 |
+
|
| 132 |
+
# -------------------- Suspiciously clean detection --------------------
|
| 133 |
+
# If ALL forensic cues are below threshold (supports_real > 0 and supports_fake == 0),
|
| 134 |
+
# AND the prediction is REAL, this could indicate a modern generator that evades detection.
|
| 135 |
+
# Flag as potentially suspicious if all cues are "clean" but confidence isn't very high.
|
| 136 |
+
suspiciously_clean = (supports_fake == 0 and supports_real >= 2 and
|
| 137 |
+
prediction_label == 0 and confidence < 0.98)
|
| 138 |
|
| 139 |
# -------------------- Triage decision (narrative only) --------------------
|
| 140 |
triage_label = base_label_str
|
| 141 |
if self.enable_triage and conflict and confidence < self.triage_conf_threshold:
|
| 142 |
triage_label = "UNCERTAIN"
|
| 143 |
+
elif self.enable_triage and suspiciously_clean and confidence < 0.95:
|
| 144 |
+
# Modern generators like Flux may evade all forensic cues
|
| 145 |
+
triage_label = "UNCERTAIN"
|
| 146 |
|
| 147 |
# Intro sentence
|
| 148 |
+
if triage_label == "UNCERTAIN" and suspiciously_clean:
|
| 149 |
+
explanation_parts.append(
|
| 150 |
+
f"⚠️ **CAUTION**: The detector predicts this image is **{base_label_str}** "
|
| 151 |
+
f"with {confidence_str} confidence ({confidence:.2f}), "
|
| 152 |
+
f"but ALL forensic cues are below threshold. This could indicate a modern generator "
|
| 153 |
+
f"(like Flux, DALL-E 3, or Midjourney v6) that evades traditional forensic detection. "
|
| 154 |
+
f"**Manual review recommended.**"
|
| 155 |
+
)
|
| 156 |
+
elif triage_label == "UNCERTAIN":
|
| 157 |
explanation_parts.append(
|
| 158 |
f"The detector predicts this image is **{base_label_str}** "
|
| 159 |
f"with {confidence_str} confidence ({confidence:.2f}), "
|
|
|
|
| 188 |
if prediction_label == 0:
|
| 189 |
explanation_parts.append(
|
| 190 |
f"- **Noiseprint**: fingerprint lies within the range seen in training real images "
|
| 191 |
+
f"(mismatch={nm:.2f} <= {thr_nm:.2f}), supporting the REAL hypothesis."
|
| 192 |
)
|
| 193 |
else:
|
| 194 |
explanation_parts.append(
|
| 195 |
f"- **Noiseprint**: fingerprint lies within the range seen in training real images "
|
| 196 |
+
f"(mismatch={nm:.2f} <= {thr_nm:.2f}), but other forensic cues indicate synthesis."
|
| 197 |
)
|
| 198 |
cues_used += 1
|
| 199 |
|
|
|
|
| 243 |
f"and conflicts with the REAL prediction."
|
| 244 |
)
|
| 245 |
cues_used += 1
|
| 246 |
+
elif prediction_label == 1:
|
| 247 |
+
# Even if below threshold, mention it if prediction is FAKE and it's close to threshold
|
| 248 |
+
if fp > thr_fp * 0.8: # Within 80% of threshold
|
| 249 |
+
explanation_parts.append(
|
| 250 |
+
f"- **Frequency spectrum**: peakiness ({fp:.2f}) is moderately elevated "
|
| 251 |
+
f"(threshold: {thr_fp:.2f}), contributing to the FAKE classification."
|
| 252 |
+
)
|
| 253 |
+
cues_used += 1
|
| 254 |
|
| 255 |
+
# -------------------- Data-driven drivers (show what actually drove the decision) --------------------
|
| 256 |
if contributions:
|
| 257 |
sorted_contribs = sorted(contributions.items(), key=lambda x: abs(x[1]), reverse=True)
|
| 258 |
+
# Show top 5-8 features for better explanation
|
| 259 |
+
top = sorted_contribs[:max(top_k_contributions, 8)]
|
| 260 |
+
pos = [(name, val) for name, val in top if val > 0]
|
| 261 |
+
neg = [(name, val) for name, val in top if val < 0]
|
| 262 |
|
| 263 |
if pos:
|
| 264 |
+
explanation_parts.append(f"\n**Features driving FAKE classification:**")
|
| 265 |
+
# Show top 5-8 features that push toward FAKE
|
| 266 |
+
pos_display = [f"{name} ({val:+.3f})" for name, val in pos[:8]]
|
| 267 |
+
explanation_parts.append(f"- {', '.join(pos_display)}")
|
| 268 |
if neg:
|
| 269 |
+
explanation_parts.append(f"\n**Features supporting REAL classification:**")
|
| 270 |
+
# Show top 3-5 features that push toward REAL
|
| 271 |
+
neg_display = [f"{name} ({val:+.3f})" for name, val in neg[:5]]
|
| 272 |
+
explanation_parts.append(f"- {', '.join(neg_display)}")
|
| 273 |
+
elif not contributions and (cues_used == 0 or (prediction_label == 1 and cues_used < 2)):
|
| 274 |
+
# If no strong forensic cues but high confidence, explain it's a combination
|
| 275 |
+
explanation_parts.append(
|
| 276 |
+
f"\n**Note**: While the primary forensic cues (Noiseprint, Residuals, FFT) don't individually "
|
| 277 |
+
f"strongly indicate synthesis, the model's decision is based on a combination of many features "
|
| 278 |
+
f"including DCT coefficients, FFT radial profiles, residual statistics, and other frequency-domain "
|
| 279 |
+
f"characteristics. The high confidence ({confidence:.1%}) suggests these subtle patterns collectively "
|
| 280 |
+
f"indicate synthetic generation."
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# List some of the other features that might be contributing
|
| 284 |
+
other_features = []
|
| 285 |
+
if 'dct_mean' in features:
|
| 286 |
+
other_features.append("DCT coefficients")
|
| 287 |
+
if 'fft_radial_mean' in features:
|
| 288 |
+
other_features.append("FFT radial profiles")
|
| 289 |
+
if 'residual_skew' in features:
|
| 290 |
+
other_features.append("residual statistics")
|
| 291 |
+
if 'residual_kurtosis' in features:
|
| 292 |
+
other_features.append("residual distribution shape")
|
| 293 |
+
|
| 294 |
+
if other_features:
|
| 295 |
+
explanation_parts.append(
|
| 296 |
+
f"The model analyzes {', '.join(other_features)} and other frequency-domain patterns "
|
| 297 |
+
f"that collectively indicate synthetic generation, even when individual cues are subtle."
|
| 298 |
+
)
|
| 299 |
|
| 300 |
# In high-conflict cases, add a final triage note
|
| 301 |
if triage_label == "UNCERTAIN" and not is_ood:
|