diff --git a/training/app.py b/training/app.py index d41d0b1..c11324e 100644 --- a/training/app.py +++ b/training/app.py @@ -406,8 +406,46 @@ def font(path, size): f_pill = font(DJ + "DejaVuSans-Bold.ttf", 16) f_lab = font(DJ + "DejaVuSans-Bold.ttf", 15) - W, H = 1200, 630 + W = 1200 + x = 508 # right column ink, muted, cream, teal = (231, 237, 234), (148, 162, 169), (217, 171, 116), (43, 196, 196) + + # ---- the full read: ALL SIX taste heads + every confident aroma head ---- + taste_src = {"sweet": out.get("sweet"), "bitter": out.get("bitter"), "umami": out.get("umami"), + "sour": out.get("sour_predicted"), "salty": out.get("salty_predicted"), + "tasteless": out.get("tasteless")} + taste_bars = sorted(((t, float(v)) for t, v in taste_src.items() if isinstance(v, (int, float))), + key=lambda kv: -kv[1]) + pa = P.predict_aroma(smi) + aroma_conf = [(d["odor"], d["score"]) for d in pa.get("descriptors", []) if d.get("confident")] + if aroma_conf: + aroma_bars = aroma_conf[:10] + aroma_label = "AROMA MODEL · confident odor heads" + else: # nothing cleared the 0.5 bar — show the strongest few, labelled honestly + aroma_bars = [(d["odor"], d["score"]) for d in pa.get("descriptors", [])[:3]] + aroma_label = "AROMA MODEL · top signals (none cleared 0.5)" + + pill_items = ([(fl, cream) for fl in tags.get("flavors", [])[:3]] + + [(t, _TASTE_RGB.get(t, teal)) for t in tags.get("tastes", [])] + + [(a, teal) for a in tags.get("aromas", [])[:6]]) + + # ---- measure pill wrapping on a scratch canvas, then compute a height that fits everything ---- + scratch = ImageDraw.Draw(Image.new("RGB", (10, 10))) + py = 306 + px = x + for text, _c in pill_items: + w = scratch.textlength(text, font=f_pill) + if px + w + 22 > W - 40: + px, py = x, py + 40 + px += w + 30 + pills_bottom = py + 40 + taste_label_y = pills_bottom + 18 + taste_bars_y = taste_label_y + 26 + aroma_label_y = taste_bars_y + len(taste_bars) * 28 + 16 + aroma_bars_y = aroma_label_y + 26 + content_bottom = aroma_bars_y + len(aroma_bars) * 28 + H = max(560, content_bottom + 62) + img = Image.new("RGB", (W, H), (15, 19, 25)) dr = ImageDraw.Draw(img) # corner aura @@ -428,60 +466,65 @@ def font(path, size): dr.text((112, 82), "taste & aroma from chemical structure", font=f_tag, fill=cream) dr.line([40, 122, W - 40, 122], fill=(42, 50, 60), width=1) - # structure panel (white) - dr.rounded_rectangle([40, 150, 470, 520], radius=14, fill=(245, 247, 245)) - d2 = rdMolDraw2D.MolDraw2DCairo(410, 350) + # structure panel (white) — grows with the card so it stays balanced + panel_bottom = min(H - 66, 620) + dr.rounded_rectangle([40, 150, 470, panel_bottom], radius=14, fill=(245, 247, 245)) + struct_h = min(360, panel_bottom - 170) + d2 = rdMolDraw2D.MolDraw2DCairo(410, struct_h) d2.drawOptions().padding = 0.12 d2.DrawMolecule(mol) d2.FinishDrawing() struct = Image.open(io.BytesIO(d2.GetDrawingText())).convert("RGBA") - img.paste(struct, (50, 160), struct) + img.paste(struct, (50, 150 + (panel_bottom - 150 - struct_h) // 2), struct) - # right column - x = 508 + # right column — name / IUPAC / SMILES dr.text((x, 158), name[:34], font=f_name, fill=ink) if iupac and iupac.lower() != name.lower(): dr.text((x, 206), ("IUPAC " + iupac)[:64], font=f_body, fill=muted) dr.text((x, 232), smi[:58], font=f_mono, fill=muted) - # "reads as" pills + # READS AS pills dr.text((x, 280), "READS AS", font=f_lab, fill=muted) px, py = x, 306 - def pill(px, py, text, fg, border): + def pill(px, py, text, fg): w = dr.textlength(text, font=f_pill) if px + w + 22 > W - 40: px, py = x, py + 40 - dr.rounded_rectangle([px, py, px + w + 22, py + 30], radius=15, outline=border, width=2) + dr.rounded_rectangle([px, py, px + w + 22, py + 30], radius=15, outline=fg, width=2) dr.text((px + 11, py + 6), text, font=f_pill, fill=fg) return px + w + 30, py - for fl in tags.get("flavors", [])[:4]: - px, py = pill(px, py, fl, cream, cream) - for t in tags.get("tastes", [])[:4]: - c = _TASTE_RGB.get(t, teal) - px, py = pill(px, py, t, c, c) - for a in tags.get("aromas", [])[:5]: - px, py = pill(px, py, a, teal, teal) - - # taste-probability bars (the numeric heads) - by = py + 58 - dr.text((x, by - 26), "TASTE MODEL", font=f_lab, fill=muted) - bars = [(t, out.get(t)) for t in ("sweet", "bitter", "umami", "tasteless") if isinstance(out.get(t), (int, float))] - bars.sort(key=lambda kv: -kv[1]) - for t, v in bars[:4]: - c = _TASTE_RGB.get(t, teal) + for text, c in pill_items: + px, py = pill(px, py, text, c) + + def bar_row(by, label, v, c): pct = int(round(v * 100)) - dr.text((x, by), t, font=f_body, fill=ink) + dr.text((x, by), label, font=f_body, fill=ink) dr.rounded_rectangle([x + 120, by + 4, x + 120 + 320, by + 16], radius=6, fill=(36, 44, 53)) - dr.rounded_rectangle([x + 120, by + 4, x + 120 + int(320 * v), by + 16], radius=6, fill=c) + if v > 0: + dr.rounded_rectangle([x + 120, by + 4, x + 120 + int(320 * v), by + 16], radius=6, fill=c) dr.text((x + 452, by), f"{pct}%", font=f_body, fill=muted) - by += 30 + + # TASTE MODEL — all six heads + dr.text((x, taste_label_y), "TASTE MODEL · all six heads", font=f_lab, fill=muted) + by = taste_bars_y + for t, v in taste_bars: + bar_row(by, t, v, _TASTE_RGB.get(t, teal)) + by += 28 + + # AROMA MODEL — every confident odor head, each with its probability + dr.text((x, aroma_label_y), aroma_label, font=f_lab, fill=muted) + by = aroma_bars_y + for a, v in aroma_bars: + bar_row(by, a, v, teal) + by += 28 # footer - dr.line([40, 576, W - 40, 576], fill=(42, 50, 60), width=1) + fy = H - 54 + dr.line([40, fy, W - 40, fy], fill=(42, 50, 60), width=1) share = "flavormancer.echelonts.net/?q=" + (common or q or smi) - dr.text((40, 590), share, font=f_mono, fill=teal) + dr.text((40, fy + 14), share, font=f_mono, fill=teal) tw = dr.textlength("before you pour", font=f_tag) - dr.text((W - 40 - tw, 588), "before you pour", font=f_tag, fill=cream) + dr.text((W - 40 - tw, fy + 12), "before you pour", font=f_tag, fill=cream) buf = io.BytesIO() img.save(buf, "PNG") diff --git a/training/workbench.html b/training/workbench.html index 848c2b0..3c08031 100644 --- a/training/workbench.html +++ b/training/workbench.html @@ -201,7 +201,7 @@ .bcell .bstruct{height:68px}.bcell .bstruct svg{max-width:100%;height:68px} .bcell .bname{font-size:12px;font-weight:600;margin-top:5px;line-height:1.25;text-transform:capitalize;cursor:pointer} .bcell .bname:hover{color:var(--aroma)} - .map-section{margin:22px 0 8px;border-top:1px solid #EEF0EC;padding-top:12px} + .map-section{margin:22px 0 8px} .map-head{font-size:12px;font-weight:650;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);cursor:pointer} .map-head .mapsub{font-weight:600;text-transform:none;letter-spacing:0;font-size:12px;color:var(--muted);opacity:.8} .map-legend{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 6px;font-size:12px} @@ -373,6 +373,12 @@ .cmp-card:hover{border-color:var(--brand-2)} .cmp-card img{display:block;width:100%;height:auto} .cmp-card .empty{padding:20px} + .cmp-card.loading{display:flex;align-items:center;justify-content:center;min-height:300px} + .cmp-card.loading img{display:none} + .cmp-load{display:flex;flex-direction:column;align-items:center;gap:8px} + .cmp-load .loader-flask{width:96px;height:96px} + .cmp-load-cap{font-family:'Cinzel',Georgia,serif;font-size:12.5px;letter-spacing:.04em;color:var(--cream)} + .cmp-err{padding:26px 20px;color:var(--muted);font-size:13px;text-align:center} .mix-ings{display:flex;flex-direction:column;gap:6px;margin-bottom:8px} .mix-ing{display:flex;gap:10px;align-items:center;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--surface)} .mix-ing:hover{background:var(--accent-soft)} @@ -784,8 +790,8 @@

Flavormancer

- -