/** * Class mapper: interpretation-to-class cascade mapping. * Ported from hf_space/class_mapper.py. */ import { CLASS_KEYWORDS, CLASSIFY_TO_DETECT, CLASSIFY_TO_SEGMENT, CO_OCCURRENCE_GROUPS, DEFAULT_DETECT_CLASSES, FIELD_WEIGHTS, GROUP_ALIASES, NEGATIVE_MEASUREMENT_PATTERNS, PLANE_TO_GROUP, SPECIFIC_CLS_LABELS, keywordLookup, } from "./constants.js"; export function parseInterpretationJson(rawText) { const text = rawText.trim(); try { const data = JSON.parse(text); if (typeof data === "object" && !Array.isArray(data)) { data._parseSuccess = true; return data; } } catch {} const bs = text.indexOf("{"), be = text.lastIndexOf("}"); if (bs !== -1 && be > bs) { try { const data = JSON.parse(text.slice(bs, be + 1)); if (typeof data === "object") { data._parseSuccess = true; return data; } } catch {} } return { _parseSuccess: false, _rawText: text }; } function flattenFieldText(value) { if (typeof value === "string") return value; if (Array.isArray(value)) return value.map(String).join(" "); if (typeof value === "object" && value !== null) return Object.values(value).map(String).join(" "); return String(value); } function scoreClassesPerField(interp, weights = FIELD_WEIGHTS) { const scores = {}; for (const cls of Object.keys(CLASS_KEYWORDS)) scores[cls] = 0; for (const [fieldName, weight] of Object.entries(weights)) { if (weight === 0) continue; const raw = interp[fieldName]; if (!raw) continue; const text = flattenFieldText(raw).toLowerCase(); if (!text) continue; for (const [cls, keywords] of Object.entries(CLASS_KEYWORDS)) { for (const kw of keywords) { if (text.includes(kw)) { scores[cls] += weight; break; } } } } return scores; } function aggregateGroupScores(classScores, interp) { let fullText = ""; for (const [k, v] of Object.entries(interp)) { if (!k.startsWith("_") && v) fullText += " " + flattenFieldText(v); } fullText = fullText.toLowerCase(); const results = CO_OCCURRENCE_GROUPS.map(([name, detCls, segCls, compat]) => { let gscore = detCls.reduce((s, c) => s + (classScores[c] || 0), 0); const aliases = GROUP_ALIASES[name] || []; for (const alias of aliases) { if (fullText.includes(alias)) { gscore += 2.0; break; } } return { name, gscore, detCls, segCls, compat }; }); results.sort((a, b) => b.gscore - a.gscore); return results; } export function extractClsLabel(clsResult) { if (!clsResult) return null; const parsed = clsResult.parsed || clsResult; const label = parsed.label; return (typeof label === "string" && label.trim()) ? label.trim() : null; } export function mapInterpretationToClasses(interpParsed, clsLabel = null) { // P1: SPECIFIC cls_label exact match if (clsLabel && SPECIFIC_CLS_LABELS.has(clsLabel) && CLASSIFY_TO_DETECT[clsLabel]) { return { det: CLASSIFY_TO_DETECT[clsLabel], seg: CLASSIFY_TO_SEGMENT[clsLabel] || null, tier: `cls_specific_${clsLabel}` }; } // P2: imaging plane / orientation -> group const planeText = flattenFieldText(interpParsed.imaging_plane || "").toLowerCase(); const orientText = flattenFieldText(interpParsed.fetal_orientation || "").toLowerCase(); const combined = `${planeText} ${orientText}`; for (const [planeKey, groupName] of Object.entries(PLANE_TO_GROUP)) { if (combined.includes(planeKey)) { for (const [gname, detCls, segCls] of CO_OCCURRENCE_GROUPS) { if (gname === groupName) { return { det: detCls.join(", "), seg: segCls ? segCls.join(", ") : null, tier: `plane_${planeKey}` }; } } break; } } // P3: keyword scoring const bioText = flattenFieldText(interpParsed.biometric_measurements || "").toLowerCase(); const hasNeg = NEGATIVE_MEASUREMENT_PATTERNS.some(p => bioText.includes(p)); let classScores; if (hasNeg) { const dampened = { ...FIELD_WEIGHTS, biometric_measurements: 0.3 }; classScores = scoreClassesPerField(interpParsed, dampened); } else { classScores = scoreClassesPerField(interpParsed); } const groups = aggregateGroupScores(classScores, interpParsed); // Disambiguation: body_full vs doppler const ORGAN_KW = ["stomach","liver","hepat","artery","arter","vein","venous","gastric","ductus"]; if (groups.length >= 2) { const gnames = {}; groups.forEach((g, i) => gnames[g.name] = i); if (gnames.body_full !== undefined && gnames.doppler !== undefined && groups[0].name === "body_full") { let ft = ""; for (const [k, v] of Object.entries(interpParsed)) { if (!k.startsWith("_") && v) ft += " " + flattenFieldText(v); } ft = ft.toLowerCase(); if (ORGAN_KW.some(kw => ft.includes(kw))) { const di = gnames.doppler; [groups[0], groups[di]] = [groups[di], groups[0]]; } } } if (groups.length > 0 && groups[0].gscore > 0) { const top = groups[0]; let detClasses = [...top.detCls]; let segClasses = top.segCls ? [...top.segCls] : null; let tier = `interp_${top.name}`; if (groups.length > 1) { const sec = groups[1]; if (sec.gscore > 0 && sec.gscore >= top.gscore * 0.3 && top.compat.includes(sec.name)) { detClasses.push(...sec.detCls); if (sec.segCls) { if (!segClasses) segClasses = []; segClasses.push(...sec.segCls); } tier = `interp_${top.name}+${sec.name}`; } } detClasses = detClasses.slice(0, 5); if (segClasses) segClasses = segClasses.slice(0, 5); return { det: detClasses.join(", "), seg: segClasses ? segClasses.join(", ") : null, tier }; } // P4: generic cls_label fallback if (clsLabel && CLASSIFY_TO_DETECT[clsLabel]) { return { det: CLASSIFY_TO_DETECT[clsLabel], seg: CLASSIFY_TO_SEGMENT[clsLabel] || null, tier: `cls_generic_${clsLabel}` }; } if (clsLabel) { const hit = keywordLookup(clsLabel); if (hit) return { det: hit.det, seg: hit.seg, tier: `cls_keyword_${hit.kw}` }; } // P5: default fallback return { det: DEFAULT_DETECT_CLASSES, seg: null, tier: "fallback_no_match" }; }