""" FBP5500 percentile rater — minimal version. Flow: pick gender -> upload -> 15 pairwise comparisons -> bell curve + percentile. """ import numpy as np import streamlit as st import matplotlib.pyplot as plt from PIL import Image from datasets import load_dataset, concatenate_datasets from scipy.stats import gaussian_kde HF_DATASET = "MnLgt/scut-fbp5500" N_BINS = 100 SCORE_MIN, SCORE_MAX = 1.0, 5.0 TAU = 0.45 EPSILON_UNIFORM = 0.08 SHARPNESS = 4 # exponent on info-gain weights; higher = more concentrated near posterior mean TARGET_COMPARISONS = 15 DISPLAY_SIZE = (350, 350) # FBP5500 images are 350x350; uploaded photo resized to match # ---- data ------------------------------------------------------------------ @st.cache_resource(show_spinner="Loading dataset...") def load_ds(): ds = load_dataset(HF_DATASET) return concatenate_datasets([ds["train"], ds["test"]]) if "test" in ds else ds["train"] @st.cache_data(show_spinner=False) def get_meta(_fp: str): """Return (scores, genders, races) as numpy arrays aligned with dataset indices.""" ds = load_ds() df = ds.remove_columns(["image"]).to_pandas() return np.array(df["beauty_score"]), np.array(df["gender"]), np.array(df["race"]) # ---- bayesian -------------------------------------------------------------- def bin_centers(): edges = np.linspace(SCORE_MIN, SCORE_MAX, N_BINS + 1) return 0.5 * (edges[:-1] + edges[1:]) def sigmoid(x): return 1.0 / (1.0 + np.exp(-x)) def update(post, ref_score, said_more): p = sigmoid((bin_centers() - ref_score) / TAU) new = post * (p if said_more else (1 - p)) s = new.sum() return new / s if s > 0 else post def info_weights(post, scores): diffs = (bin_centers()[None, :] - scores[:, None]) / TAU p = sigmoid(diffs) e = (p * post[None, :]).sum(axis=1) return e * (1 - e) def sample_idx(pool_indices, all_scores, post, seen, rng): """Sample from pool_indices (dataset indices), excluding seen.""" avail = np.array([i for i in pool_indices if i not in seen]) if len(avail) == 0: avail = pool_indices w = info_weights(post, all_scores[avail]) w = w ** SHARPNESS w = (1 - EPSILON_UNIFORM) * (w / w.sum()) + EPSILON_UNIFORM / len(w) return int(rng.choice(avail, p=w)) # ---- image helpers --------------------------------------------------------- def fit_to_size(img: Image.Image, size=DISPLAY_SIZE) -> Image.Image: """Resize image to fit within `size` keeping aspect ratio, then pad to exact size.""" img = img.copy() img.thumbnail(size, Image.LANCZOS) # pad with neutral background to exact size canvas = Image.new("RGB", size, (240, 240, 240)) x = (size[0] - img.width) // 2 y = (size[1] - img.height) // 2 canvas.paste(img, (x, y)) return canvas # ---- UI -------------------------------------------------------------------- st.set_page_config(page_title="Face Rater", layout="wide") st.markdown(""" """, unsafe_allow_html=True) ds = load_ds() all_scores, all_genders, all_races = get_meta(ds._fingerprint) # session init if "posterior" not in st.session_state: st.session_state.posterior = np.ones(N_BINS) / N_BINS st.session_state.seen = set() st.session_state.n_done = 0 st.session_state.current_idx = None st.session_state.rng = np.random.default_rng() st.session_state.done = False st.session_state.gender = None st.session_state.race = None st.session_state.pool_indices = None # step 1: gender pick if st.session_state.gender is None: st.title("Face percentile rater") st.write("Compare against which group?") c1, c2 = st.columns(2) if c1.button("Men", use_container_width=True): st.session_state.gender = "Male" st.rerun() if c2.button("Women", use_container_width=True): st.session_state.gender = "Female" st.rerun() st.stop() # step 1b: race pick if st.session_state.race is None: st.title("Face percentile rater") st.write(f"Selected: {st.session_state.gender.lower()}s") st.write("Compare against which race?") c1, c2, c3 = st.columns(3) if c1.button("Caucasian", use_container_width=True): st.session_state.race = "Caucasian" st.rerun() if c2.button("Asian", use_container_width=True): st.session_state.race = "Asian" st.rerun() if c3.button("Both", use_container_width=True): st.session_state.race = "Both" st.rerun() st.stop() # precompute pool indices for the chosen gender + race if st.session_state.pool_indices is None: gender_mask = all_genders == st.session_state.gender if st.session_state.race == "Both": mask = gender_mask else: mask = gender_mask & (all_races == st.session_state.race) st.session_state.pool_indices = np.where(mask)[0] # step 2: upload OR pick a calibration face if "uploaded_img" not in st.session_state: st.title("Face percentile rater") race_label = "Asian + Caucasian" if st.session_state.race == "Both" else st.session_state.race st.caption(f"Comparing against: {race_label} {st.session_state.gender.lower()}s " f"({len(st.session_state.pool_indices)} reference faces)") uploaded = st.file_uploader("Upload a face photo", type=["jpg", "jpeg", "png", "webp"]) if uploaded is not None: st.session_state.uploaded_img = Image.open(uploaded).convert("RGB") # mark that this came from upload (so we don't exclude it from sampling) st.session_state.calibration_idx = None st.rerun() st.markdown("**Or test the rater with a known face from the dataset:**") st.caption("Picks a face from FBP5500 near the chosen percentile. See if the rater recovers it.") pool_scores = all_scores[st.session_state.pool_indices] sorted_pool_scores = np.sort(pool_scores) percentiles = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] # 11 buttons in a row cols = st.columns(11) for col, p in zip(cols, percentiles): if col.button(str(p), key=f"pct_{p}", use_container_width=True): # find the score at the chosen percentile within the gender pool target_score = float(np.percentile(pool_scores, p)) # pick a face whose score is closest to target (with a small random jitter # among the k nearest so repeated clicks don't always show the same face) pool_idx_array = st.session_state.pool_indices scores_in_pool = all_scores[pool_idx_array] distances = np.abs(scores_in_pool - target_score) k_nearest = np.argsort(distances)[:5] # 5 closest faces chosen_local = st.session_state.rng.choice(k_nearest) chosen_idx = int(pool_idx_array[chosen_local]) st.session_state.uploaded_img = ds[chosen_idx]["image"].convert("RGB") st.session_state.calibration_idx = chosen_idx st.session_state.calibration_true_score = float(ds[chosen_idx]["beauty_score"]) # exclude this face from being shown as a reference st.session_state.seen.add(chosen_idx) st.rerun() st.stop() uploaded_img = st.session_state.uploaded_img uploaded_display = fit_to_size(uploaded_img) # step 4: end screen (KDE on the filtered pool) if st.session_state.done: centers = bin_centers() theta_hat = float((st.session_state.posterior * centers).sum()) pool_scores = all_scores[st.session_state.pool_indices] sorted_scores = np.sort(pool_scores) pct = float(np.searchsorted(sorted_scores, theta_hat, side="right") / len(sorted_scores) * 100) kde = gaussian_kde(pool_scores, bw_method=0.3) xs = np.linspace(1, 5, 400) ys = kde(xs) fig, ax = plt.subplots(figsize=(10, 5)) ax.fill_between(xs, ys, color="#cdd9e5", alpha=0.6) ax.plot(xs, ys, color="#5a7184", linewidth=2) y_at = float(kde(theta_hat)[0]) ax.plot([theta_hat, theta_hat], [0, y_at], color="#c0392b", linewidth=2.5) ax.scatter([theta_hat], [y_at], color="#c0392b", s=120, zorder=5) ax.annotate(f" you ({pct:.0f}th pct)", xy=(theta_hat, y_at), xytext=(theta_hat + 0.05, y_at + max(ys) * 0.05), fontsize=13, color="#c0392b", fontweight="bold") # if this was a calibration face, plot the ground truth too cal_score = st.session_state.get("calibration_true_score") if cal_score is not None: true_pct = float(np.searchsorted(sorted_scores, cal_score, side="right") / len(sorted_scores) * 100) y_true = float(kde(cal_score)[0]) ax.plot([cal_score, cal_score], [0, y_true], color="#27ae60", linewidth=2.5, linestyle="--") ax.scatter([cal_score], [y_true], color="#27ae60", s=120, zorder=5, marker="D") ax.annotate(f" truth ({true_pct:.0f}th pct)", xy=(cal_score, y_true), xytext=(cal_score + 0.05, y_true + max(ys) * 0.12), fontsize=13, color="#27ae60", fontweight="bold") ax.set_xlim(1, 5) ax.set_ylim(0, max(ys) * 1.2) race_label = "Asian + Caucasian" if st.session_state.race == "Both" else st.session_state.race ax.set_xlabel(f"FBP5500 beauty score ({race_label} {st.session_state.gender.lower()}s)", fontsize=12) ax.set_yticks([]) for spine in ("top", "right", "left"): ax.spines[spine].set_visible(False) ax.set_title(f"{pct:.0f}th percentile (θ ≈ {theta_hat:.2f})", fontsize=16, pad=15) st.pyplot(fig) c1, c2 = st.columns(2) with c1: st.image(uploaded_display, caption="Your photo", use_container_width=True) with c2: if st.button("Start over", use_container_width=True): for k in list(st.session_state.keys()): del st.session_state[k] st.rerun() st.stop() # step 3: comparison loop if st.session_state.current_idx is None: st.session_state.current_idx = sample_idx( st.session_state.pool_indices, all_scores, st.session_state.posterior, st.session_state.seen, st.session_state.rng ) cur = st.session_state.current_idx row = ds[cur] ref_score = float(row["beauty_score"]) ref_display = fit_to_size(row["image"]) col_l, col_r = st.columns(2, gap="medium") with col_l: st.image(uploaded_display, use_container_width=True) pick_left = st.button("This one", key="pick_left", use_container_width=True) with col_r: st.image(ref_display, use_container_width=True) pick_right = st.button("This one", key="pick_right", use_container_width=True) def advance(said_more: bool): st.session_state.posterior = update(st.session_state.posterior, ref_score, said_more) st.session_state.seen.add(cur) st.session_state.n_done += 1 st.session_state.current_idx = None if st.session_state.n_done >= TARGET_COMPARISONS: st.session_state.done = True if pick_left: advance(True); st.rerun() if pick_right: advance(False); st.rerun() remaining = TARGET_COMPARISONS - st.session_state.n_done st.divider() st.progress(st.session_state.n_done / TARGET_COMPARISONS, text=f"{remaining} more rating{'s' if remaining != 1 else ''} to go")