Spaces:
Running
Running
Download static/index.html from Enzo-py/partial-prompting-study: direct link, hf CLI and curl.
- Browser
- Download file 35.8 kB
-
https://huggingface.co/spaces/Enzo-py/partial-prompting-study/resolve/main/static/index.html
- Command line
-
hf download hf://spaces/Enzo-py/partial-prompting-study/static/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Enzo-py/partial-prompting-study/resolve/main/static/index.html
35.8 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Image and Text Study</title> | |
| <link rel="stylesheet" href="/static/vendor/jspsych-7.3.4.css"> | |
| <style> | |
| /* Deliberately plain: system font, no boxes, one accent for selection and the main button. */ | |
| :root{ | |
| --bg:#ffffff; --ink:#1a1a1a; --muted:#6b6b6b; --line:#dcdcdc; --hover:#f4f4f4; | |
| --accent:#2457a6; --accent-soft:#eef3fb; --warn:#b4530f; --bad:#b3261e; --bad-soft:#fdf1f0; --mark:#fff0a8; | |
| --font:system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | |
| --mono:ui-monospace, Menlo, Consolas, monospace; | |
| color-scheme:light; | |
| } | |
| @media (prefers-color-scheme: dark){:root{ | |
| --bg:#161616; --ink:#ececec; --muted:#a0a0a0; --line:#3a3a3a; --hover:#212121; | |
| --accent:#8ab4f8; --accent-soft:#1e2a3d; --warn:#f0a868; --bad:#f2b8b5; --bad-soft:#2c1b1a; --mark:#5a4a00; color-scheme:dark}} | |
| html,body{background:var(--bg);color:var(--ink)} | |
| body{margin:0;font-family:var(--font);font-size:16px;line-height:1.5} | |
| .jspsych-display-element{font-family:var(--font);font-size:16px;line-height:1.5;color:var(--ink)} | |
| .jspsych-content-wrapper{align-items:flex-start} | |
| .jspsych-content{max-width:640px;width:100%;text-align:left;margin:0 auto;padding:24px 16px 48px;box-sizing:border-box} | |
| .jspsych-content:has(.rank){max-width:1120px} /* rankings use the width for the images */ | |
| .jspsych-content p{margin:0} | |
| img{max-width:100%} | |
| /* The same two-step progress on every page: 1 rating, 2 ranking. */ | |
| .progress{display:flex;gap:12px;margin-bottom:6px} | |
| .progress .step{flex:1;display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--muted);min-width:0} | |
| .progress .step.on{color:var(--ink)} | |
| .progress .step span:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| .bar{display:block;height:4px;border-radius:2px;background:var(--line);overflow:hidden} | |
| .bar i{display:block;height:100%;background:var(--muted);transition:width .2s} | |
| .progress .step.on .bar i{background:var(--accent)} | |
| .eyebrow{display:block;font-size:13px;color:var(--muted);margin-bottom:16px;min-height:1.5em} | |
| h1{font-size:22px;font-weight:600;line-height:1.3;margin:0 0 8px} | |
| .card{display:flex;flex-direction:column;gap:24px} | |
| .card ul,.card ol{margin:0;padding-left:20px;display:flex;flex-direction:column;gap:4px} | |
| .muted{color:var(--muted)} | |
| .prompt{display:flex;flex-direction:column;gap:2px} | |
| .prompt .lbl{font-size:13px;color:var(--muted)} | |
| .prompt q{font-size:22px;font-weight:600;line-height:1.3;quotes:none} | |
| .prompt q.text{quotes:"“" "”"} /* quotes only around the text being rated */ | |
| .stim{position:relative;border-radius:4px;overflow:hidden;line-height:0;user-select:none;width:fit-content;max-width:100%;margin:0 auto;touch-action:manipulation} | |
| /* Fits the screen height so the questions stay close; the frame hugs the image, so marks land where tapped. */ | |
| .stim img{display:block;width:auto;height:auto;max-width:100%;max-height:65vh} | |
| .stim.marking{cursor:crosshair;outline:2px solid var(--warn);outline-offset:-2px} | |
| .stim .mk{position:absolute;width:28px;height:28px;margin:-14px 0 0 -14px;border:4px solid #e61414;border-radius:50%;box-sizing:border-box;pointer-events:none;box-shadow:0 0 0 2px #fff} | |
| .stim canvas.seg{position:absolute;inset:0;width:100%;height:100%;pointer-events:none} | |
| .stim .hint{position:absolute;left:8px;bottom:8px;line-height:1.3;background:var(--bg);color:var(--warn);font-size:13px;padding:4px 8px;border-radius:4px} | |
| .style{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-bottom:-14px} | |
| .style .tag{font-size:12px;font-weight:500;color:var(--ink);background:var(--hover);border-radius:4px;padding:2px 8px;white-space:nowrap} | |
| .q{display:flex;flex-direction:column;gap:8px} | |
| .q h3{font-size:16px;font-weight:600;margin:0} | |
| .likert{display:grid;grid-template-columns:repeat(5,1fr);gap:6px} | |
| .row{display:flex;gap:6px;flex-wrap:wrap} | |
| .row .opt{flex:1 1 90px} | |
| .opt{position:relative} | |
| .opt input{position:absolute;opacity:0;pointer-events:none} | |
| .opt label{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;min-height:54px;padding:6px 4px;text-align:center;border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);line-height:1.25} | |
| .opt label:hover{background:var(--hover)} | |
| .row .opt label{min-height:42px;font-size:14px;color:var(--ink)} | |
| .opt label b{font-size:17px;font-weight:600;color:var(--ink)} | |
| .opt input:checked + label{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)} | |
| .opt input:focus-visible + label{outline:2px solid var(--accent);outline-offset:2px} | |
| .marks{display:flex;gap:10px;align-items:center;font-size:13px;color:var(--muted)} | |
| .nav{display:flex;justify-content:flex-end;gap:10px} | |
| .rank{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:12px;align-items:start;width:100%;max-width:calc(var(--cols) * 400px);margin:0 auto} | |
| .rk{position:relative;display:block;padding:0;border:3px solid transparent;border-radius:6px;background:var(--hover);cursor:pointer;line-height:0;overflow:hidden;touch-action:manipulation} | |
| .rk img{width:100%;height:auto;display:block;border-radius:3px} | |
| .rk.on{border-color:var(--accent)} | |
| .rk:focus-visible{outline:2px solid var(--accent);outline-offset:2px} | |
| .rk .badge{position:absolute;top:6px;left:6px;width:30px;height:30px;border-radius:50%;background:var(--accent);color:var(--bg);font:600 16px/30px var(--font);text-align:center} | |
| .fb{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--line);padding-top:20px} | |
| .fbcase{display:grid;grid-template-columns:minmax(0,200px) 1fr;gap:16px;align-items:start;margin-top:4px} | |
| .fbtext{display:flex;flex-direction:column;gap:6px} | |
| .fbtext .lbl{font-size:13px;color:var(--muted)} | |
| .fbtext q{font-size:18px;font-weight:600;line-height:1.3;quotes:"“" "”"} | |
| @media (max-width:480px){.fbcase{grid-template-columns:1fr}} | |
| button.b{font:500 15px var(--font);border-radius:6px;padding:9px 18px;min-height:44px;cursor:pointer;border:1px solid var(--line);background:var(--bg);color:var(--ink)} | |
| button.b.primary{background:var(--accent);border-color:var(--accent);color:var(--bg)} | |
| button.b:disabled{opacity:.35;cursor:not-allowed} | |
| button.b:focus-visible{outline:2px solid var(--accent);outline-offset:2px} | |
| button.link{border:0;background:none;padding:0;color:var(--accent);text-decoration:underline;font-size:13px;cursor:pointer} | |
| .code{font:600 18px var(--mono);letter-spacing:.04em;padding:8px 12px;background:var(--hover);border-radius:4px;align-self:flex-start;user-select:all} | |
| .err{color:var(--bad)} | |
| /* Red notice at the start of each task; <mark> highlights that the answers are the participant's own judgement. */ | |
| .notice{display:flex;flex-direction:column;gap:8px;padding:12px 14px;border:1px solid var(--bad);border-left-width:4px;border-radius:6px;background:var(--bad-soft);color:var(--bad);font-size:15px} | |
| .notice b{color:inherit} | |
| mark{background:var(--mark);color:var(--ink);text-decoration:underline;text-underline-offset:3px;padding:0 3px;border-radius:2px} | |
| @media (max-width:719px){.rank{--cols:2;gap:8px}} | |
| @media (max-width:480px){ | |
| .jspsych-content{padding:16px 12px 40px} | |
| .card{gap:20px} | |
| .likert{gap:4px} | |
| .opt label{font-size:11px;min-height:60px;padding:6px 2px} | |
| .prompt q{font-size:20px} | |
| .nav .b{flex:1} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="jspsych-target"></div> | |
| <noscript>This study needs JavaScript.</noscript> | |
| <script src="/static/vendor/jspsych-7.3.4.min.js"></script> | |
| <script> | |
| const PT = jsPsychModule.ParameterType; | |
| const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); | |
| // How each dataset `visual_style` is named to participants; other values are shown as written. | |
| const STYLES = { "svg-illustration": "Simple illustration", "photo": "Photograph", "ai-generated": "AI-generated image" }; | |
| const styleLabel = v => v ? STYLES[v] || v.replace(/[-_]/g, " ").replace(/^./, c => c.toUpperCase()) : ""; | |
| const src = f => "/stimuli/" + f.split("/").map(encodeURIComponent).join("/"); | |
| // Two-step progress shown at the top of every page. `rate` and `rank` are each 0..1; `on` is the | |
| // current step (1 or 2, none before the study); `note` is the line under it. | |
| let STEPS = { rank: true }; | |
| const progress = ({ rate = 0, rank = 0, on = 0, note = "" } = {}) => ` | |
| <div class="progress" aria-hidden="true"> | |
| <div class="step${on === 1 ? " on" : ""}"><span>1 · Rate images</span><span class="bar"><i style="width:${100 * rate}%"></i></span></div> | |
| ${STEPS.rank ? `<div class="step${on === 2 ? " on" : ""}"><span>2 · Rank images</span><span class="bar"><i style="width:${100 * rank}%"></i></span></div>` : ""} | |
| </div><span class="eyebrow">${note}</span>`; | |
| // Image loading: each step's images start loading on the text page before it; a trial whose images | |
| // are not there yet shows a loading screen until they are. | |
| const imageURL = im => im.src || src(im.file_name); | |
| const LOADS = new Map(), LOADED = new Set(), IMAGES = new Map(); | |
| const load = url => { | |
| if (!LOADS.has(url)) LOADS.set(url, new Promise(ok => { | |
| const i = new Image(); IMAGES.set(url, i); i.onload = i.onerror = () => { LOADED.add(url); ok(); }; i.src = url; })); | |
| return LOADS.get(url); | |
| }; | |
| // Question 2 is turned off (see StimulusPlugin), so the segmentation maps are not loaded. | |
| // const stimURLs = s => [imageURL(s), ...(s.seg_file ? [src(s.seg_file)] : [])]; | |
| const stimURLs = s => [imageURL(s)]; | |
| // A loaded segmentation map: idAt(x, y) for x, y in 0..1, and draw() on a canvas laid over the image: | |
| // selected parts are hatched and outlined in red, marking them as the problem (not as a highlight); | |
| // the part under the mouse is outlined lightly. | |
| const SEGMAPS = new Map(); | |
| function segmentMap(url) { | |
| if (SEGMAPS.has(url)) return SEGMAPS.get(url); | |
| // A map that did not load, cannot be read or has no part to select gives null: points instead. | |
| const img = IMAGES.get(url), w = img ? img.naturalWidth : 0, h = img ? img.naturalHeight : 0; | |
| if (!w || !h) return null; | |
| let ids; | |
| try { | |
| const c = document.createElement("canvas"); c.width = w; c.height = h; | |
| const ctx = c.getContext("2d", { willReadFrequently: true }); ctx.drawImage(img, 0, 0); | |
| const px = ctx.getImageData(0, 0, w, h).data; ids = new Uint32Array(w * h); | |
| for (let k = 0; k < ids.length; k++) ids[k] = px[4 * k] + 256 * px[4 * k + 1] + 65536 * px[4 * k + 2]; | |
| } catch (e) { return null; } | |
| if (!ids.some(id => id)) return null; | |
| const map = { | |
| w, h, | |
| idAt: (x, y) => ids[Math.min(h - 1, Math.floor(y * h)) * w + Math.min(w - 1, Math.floor(x * w))], | |
| draw(canvas, selected, hover) { | |
| const g = canvas.getContext("2d"), out = g.createImageData(w, h), d = out.data, sel = new Set(selected); | |
| const stripe = Math.max(1, Math.round(w / 512)); // stripes of about the same width on screen | |
| for (let k = 0; k < ids.length; k++) { | |
| const id = ids[k], on = sel.has(id); | |
| if (!on && (!id || id !== hover)) continue; | |
| const x = k % w, y = (k - x) / w, edge = (x > 0 && ids[k - 1] !== id) || (x < w - 1 && ids[k + 1] !== id) || | |
| (k >= w && ids[k - w] !== id) || (k < ids.length - w && ids[k + w] !== id) || | |
| (x > 1 && ids[k - 2] !== id) || (x < w - 2 && ids[k + 2] !== id) || | |
| (k >= 2 * w && ids[k - 2 * w] !== id) || (k < ids.length - 2 * w && ids[k + 2 * w] !== id); | |
| let c = null; | |
| if (on) c = edge ? [230, 20, 20, 255] // selected: red outline, red hatching | |
| : (x + y) % (8 * stripe) < 3 * stripe ? [230, 20, 20, 190] : [230, 20, 20, 40]; | |
| else c = edge ? [255, 255, 255, 230] : [255, 255, 255, 60]; // under the mouse | |
| if (c) { d[4 * k] = c[0]; d[4 * k + 1] = c[1]; d[4 * k + 2] = c[2]; d[4 * k + 3] = c[3]; } | |
| } | |
| g.putImageData(out, 0, 0); | |
| }, | |
| }; | |
| SEGMAPS.set(url, map); | |
| return map; | |
| } | |
| const preload = urls => urls.forEach(load); | |
| async function whenLoaded(el, urls, prog) { | |
| if (urls.every(u => LOADED.has(u))) return; | |
| const draw = () => { el.innerHTML = `${progress(prog)}<div class="card loading" role="status"> | |
| <div class="prompt"><span class="lbl">Loading</span><q>Loading the images…</q></div> | |
| <p class="muted">${urls.filter(u => LOADED.has(u)).length} of ${urls.length} ready. This only takes a moment.</p></div>`; }; | |
| draw(); | |
| await Promise.all(urls.map(u => load(u).then(draw))); | |
| el.innerHTML = ""; | |
| } | |
| const radios = (name, opts, fmt) => opts.map(([v, l]) => ` | |
| <div class="opt"><input type="radio" id="${name}-${v}" name="${name}" value="${v}"><label for="${name}-${v}">${fmt(v, l)}</label></div>`).join(""); | |
| /* ---------------------------------------------------------------- plugins */ | |
| class PagePlugin { | |
| static info = { name: "page", parameters: { | |
| html: { type: PT.HTML_STRING, default: "" }, button: { type: PT.STRING, default: "Continue" }, | |
| progress: { type: PT.OBJECT, default: {} } } }; | |
| constructor(jsPsych) { this.jsPsych = jsPsych; } | |
| trial(el, t) { | |
| const t0 = performance.now(); | |
| window.scrollTo({ top: 0 }); | |
| el.innerHTML = `${progress(t.progress)}<div class="card">${t.html}<div class="nav"><button class="b primary" id="go">${t.button}</button></div></div>`; | |
| el.querySelector("#go").onclick = () => { el.innerHTML = ""; this.jsPsych.finishTrial({ trial_kind: "page", rt: Math.round(performance.now() - t0) }); }; | |
| } | |
| } | |
| class ProfilePlugin { | |
| static info = { name: "profile", parameters: {} }; | |
| constructor(jsPsych) { this.jsPsych = jsPsych; } | |
| trial(el) { | |
| const Q = [ | |
| ["english", "How would you describe your English?", "", [["native","Native"],["fluent","Fluent"],["intermediate","Intermediate"]]], | |
| ["t2i", "How often do you use AI image generators?", "For example Midjourney, DALL·E, Stable Diffusion, Imagen.", [["never","Never"],["sometimes","A few times"],["regularly","Regularly"]]], | |
| ]; | |
| el.innerHTML = `${progress()}<div class="card"><div class="prompt"><span class="lbl">Before you start</span><q>About you</q></div> | |
| ${Q.map(([k, l, sub, o]) => `<div class="q"><h3>${l}</h3>${sub ? `<p class="muted" style="margin-top:-6px;font-size:13px">${sub}</p>` : ""} | |
| <div class="row" role="radiogroup">${radios(k, o, (v, x) => x)}</div></div>`).join("")} | |
| <div class="nav"><button class="b primary" id="go" disabled>Start</button></div></div>`; | |
| const ans = {}; | |
| const go = el.querySelector("#go"); | |
| Q.forEach(([k]) => el.querySelectorAll(`input[name="${k}"]`).forEach(i => i.onchange = () => { | |
| ans[k] = i.value; go.disabled = !Q.every(([q]) => ans[q]); })); | |
| go.onclick = () => { el.innerHTML = ""; this.jsPsych.finishTrial({ trial_kind: "profile", ...ans }); }; | |
| } | |
| } | |
| // Question 2 marks what doesn't match: on images with a segmentation (`seg_file`, COCO panoptic: segment | |
| // id = R + 256·G + 256²·B) a click selects or unselects the whole segment under it; on other images | |
| // it places a point. | |
| class StimulusPlugin { | |
| static info = { name: "stimulus", parameters: { | |
| stim: { type: PT.OBJECT, default: undefined }, index: { type: PT.INT, default: 0 }, total: { type: PT.INT, default: 0 }, | |
| practice: { type: PT.BOOL, default: false }, last: { type: PT.BOOL, default: false } } }; | |
| constructor(jsPsych) { this.jsPsych = jsPsych; } | |
| trial(el, t) { | |
| return whenLoaded(el, stimURLs(t.stim), this.prog(t)).then(() => this.show(el, t)); | |
| } | |
| prog(t) { | |
| return t.practice ? { on: 1, note: "Practice · this one is not used, it is only to try the questions" } | |
| : { on: 1, rate: t.index / t.total, note: `Image ${t.index + 1} of ${t.total}` }; | |
| } | |
| show(el, t) { | |
| const s = t.stim, t0 = performance.now(); | |
| // const seg = s.seg_file ? segmentMap(src(s.seg_file)) : null; | |
| const seg = null; // question 2 is turned off | |
| const r = { rating: null, problem: null, marks: [], segments: [], rating_changes: 0, rt_first_rating: null }; | |
| el.innerHTML = ` | |
| ${progress(this.prog(t))} | |
| <div class="card"> | |
| <div class="prompt"><span class="lbl">Text</span><q class="text">${esc(s.prompt)}</q></div> | |
| ${s.visual_style ? `<div class="style"><span class="tag">${esc(styleLabel(s.visual_style))}</span>Judge what is shown, not the drawing style.</div>` : ""} | |
| <div class="stim" id="stim"><img src="${imageURL(s)}" alt="" draggable="false">${seg ? `<canvas class="seg" width="${seg.w}" height="${seg.h}"></canvas>` : ""}</div> | |
| <div class="q"><h3>How well does the image match the text?</h3> | |
| <div class="likert" role="radiogroup">${radios("rating", [[1,"Does not match"],[2,"Poorly"],[3,"Partly"],[4,"Mostly"],[5,"Fully matches"]], (v, l) => `<b>${v}</b>${l}`)}</div></div> | |
| ${"" /* Question 2, turned off for now: uncomment it and the lines marked "question 2" below. | |
| <div class="q"><h3>2. Is there anything in the image that doesn’t match the text?</h3> | |
| <div class="row" role="radiogroup">${radios("problem", [["no","No"],["yes","Yes"]], (v, l) => l)}</div> | |
| <div class="marks" id="marks" hidden></div></div> */} | |
| <div class="nav"><button class="b primary" id="next" disabled>${t.practice ? "Start the study" : t.last ? "Submit my answers" : "Next"}</button></div> | |
| </div>`; | |
| window.scrollTo({ top: 0 }); | |
| const stim = el.querySelector("#stim"), marksEl = el.querySelector("#marks"), next = el.querySelector("#next"); | |
| const canvas = stim.querySelector("canvas.seg"); | |
| const tap = matchMedia("(pointer: coarse)").matches ? "Tap" : "Click"; | |
| let hover = 0; | |
| const count = () => seg ? r.segments.length : r.marks.length; | |
| const refresh = () => { | |
| const marking = r.problem === "yes"; | |
| stim.classList.toggle("marking", marking); | |
| stim.querySelectorAll(".mk,.hint").forEach(n => n.remove()); | |
| if (seg) seg.draw(canvas, r.segments, marking ? hover : 0); | |
| else r.marks.forEach(m => { const d = document.createElement("div"); d.className = "mk"; d.style.left = (m.x * 100) + "%"; d.style.top = (m.y * 100) + "%"; stim.appendChild(d); }); | |
| if (marking) { | |
| const h = document.createElement("span"); h.className = "hint"; h.textContent = `${tap} each part that doesn’t match`; stim.appendChild(h); | |
| marksEl.hidden = false; | |
| const n = count(), what = seg ? "part" : "area"; | |
| marksEl.innerHTML = n ? `<span>${n} ${what}${n === 1 ? "" : "s"} selected${seg ? ` · ${tap.toLowerCase()} a part again to unselect it` : ""}</span><button class="link" id="clr">Clear</button>` | |
| : `<span>Select at least one ${what} on the image.</span>`; | |
| const c = marksEl.querySelector("#clr"); if (c) c.onclick = () => { r.marks = []; r.segments = []; refresh(); }; | |
| } else if (marksEl) marksEl.hidden = true; | |
| // next.disabled = !(r.rating && r.problem) || (r.problem === "yes" && !count()); // question 2 | |
| next.disabled = !r.rating; | |
| }; | |
| el.querySelectorAll('input[name="rating"]').forEach(i => i.onchange = () => { | |
| if (r.rating !== null) r.rating_changes++; else r.rt_first_rating = Math.round(performance.now() - t0); | |
| r.rating = +i.value; refresh(); }); | |
| el.querySelectorAll('input[name="problem"]').forEach(i => i.onchange = () => { r.problem = i.value; if (i.value === "no") { r.marks = []; r.segments = []; } refresh(); }); | |
| const at = ev => { | |
| const b = stim.querySelector("img").getBoundingClientRect(); | |
| const x = (ev.clientX - b.left) / b.width, y = (ev.clientY - b.top) / b.height; | |
| return x < 0 || x > 1 || y < 0 || y > 1 ? null : { x: +x.toFixed(4), y: +y.toFixed(4) }; | |
| }; | |
| stim.addEventListener("click", ev => { | |
| if (r.problem !== "yes") return; | |
| const p = at(ev); if (!p) return; | |
| if (seg) { | |
| const id = seg.idAt(p.x, p.y); if (!id) return; | |
| const k = r.segments.indexOf(id); | |
| if (k < 0) { r.segments.push(id); r.marks.push({ ...p, segment: id }); } | |
| else { r.segments.splice(k, 1); r.marks = r.marks.filter(m => m.segment !== id); } | |
| } else r.marks.push(p); | |
| refresh(); | |
| }); | |
| if (seg) { | |
| stim.addEventListener("mousemove", ev => { | |
| if (r.problem !== "yes") return; | |
| const p = at(ev), id = p ? seg.idAt(p.x, p.y) : 0; | |
| if (id !== hover) { hover = id; seg.draw(canvas, r.segments, hover); } | |
| }); | |
| stim.addEventListener("mouseleave", () => { if (hover) { hover = 0; seg.draw(canvas, r.segments, 0); } }); | |
| } | |
| next.onclick = () => { | |
| const data = { trial_kind: t.practice ? "practice" : "stimulus", position: t.index + 1, sample_id: s.sample_id, prompt: s.prompt, | |
| rating: r.rating, problem: r.problem, marking: seg ? "segments" : "points", marks: r.marks, segments: r.segments, | |
| rating_changes: r.rating_changes, | |
| rt_first_rating: r.rt_first_rating, rt: Math.round(performance.now() - t0) }; | |
| el.innerHTML = ""; this.jsPsych.finishTrial(data); | |
| }; | |
| } | |
| } | |
| // Several images for one text: tap them from the one that fits the text best to the one that fits it least. Tapping a ranked image | |
| // takes it out; every image is placed by the participant, including the last. | |
| class RankingPlugin { | |
| static info = { name: "ranking", parameters: { | |
| set: { type: PT.OBJECT, default: undefined }, index: { type: PT.INT, default: 0 }, total: { type: PT.INT, default: 0 }, | |
| practice: { type: PT.BOOL, default: false }, last: { type: PT.BOOL, default: false } } }; | |
| constructor(jsPsych) { this.jsPsych = jsPsych; } | |
| trial(el, t) { | |
| return whenLoaded(el, t.set.images.map(imageURL), this.prog(t)).then(() => this.show(el, t)); | |
| } | |
| prog(t) { | |
| return t.practice ? { on: 2, rate: 1, note: "Practice · this one is not used, it is only to try the ranking" } | |
| : { on: 2, rate: 1, rank: t.index / t.total, note: `Ranking ${t.index + 1} of ${t.total}` }; | |
| } | |
| show(el, t) { | |
| const r = t.set, t0 = performance.now(); | |
| const shown = this.jsPsych.randomization.shuffle(r.images); | |
| let order = [], resets = 0; | |
| el.innerHTML = ` | |
| ${progress(this.prog(t))} | |
| <div class="card"> | |
| <div class="prompt"><span class="lbl">Text</span><q class="text">${esc(r.prompt)}</q></div> | |
| ${r.visual_style ? `<div class="style"><span class="tag">${esc(styleLabel(r.visual_style))}</span>Judge what is shown, not the drawing style.</div>` : ""} | |
| <p>Tap the images from the one that fits the text best to the one that fits it least. If some fit equally well, put first the one you would rather get.</p> | |
| <div class="rank" style="--cols:${shown.length === 4 ? 2 : Math.min(shown.length, 3)}">${shown.map(im => `<button type="button" class="rk" data-id="${esc(im.image_id)}"><img src="${imageURL(im)}" alt="" draggable="false"><span class="badge" hidden></span></button>`).join("")}</div> | |
| <div class="marks" id="status"></div> | |
| <div class="nav"><button class="b" id="reset">Start over</button><button class="b primary" id="next" disabled>${t.practice ? "Start the ranking" : t.last ? "Submit my answers" : "Next"}</button></div> | |
| </div>`; | |
| window.scrollTo({ top: 0 }); | |
| const next = el.querySelector("#next"), status = el.querySelector("#status"); | |
| const ordinal = n => ["first", "second", "third", "fourth", "fifth", "sixth"][n - 1] || `#${n}`; | |
| const refresh = () => { | |
| el.querySelectorAll(".rk").forEach(b => { | |
| const k = order.indexOf(b.dataset.id), badge = b.querySelector(".badge"); | |
| badge.hidden = k < 0; badge.textContent = k + 1; b.classList.toggle("on", k >= 0); | |
| b.setAttribute("aria-label", k < 0 ? "Not ranked yet" : `Ranked ${k + 1}`); | |
| }); | |
| status.textContent = order.length < shown.length ? `Tap the image you would put ${ordinal(order.length + 1)}.` : "Done. Tap an image to take it out, or Start over."; | |
| next.disabled = order.length !== shown.length; | |
| }; | |
| el.querySelectorAll(".rk").forEach(b => b.onclick = () => { | |
| const id = b.dataset.id; | |
| order = order.includes(id) ? order.filter(x => x !== id) : [...order, id]; | |
| refresh(); | |
| }); | |
| el.querySelector("#reset").onclick = () => { if (order.length) resets++; order = []; refresh(); }; | |
| next.onclick = () => { | |
| el.innerHTML = ""; | |
| this.jsPsych.finishTrial({ trial_kind: t.practice ? "practice_ranking" : "ranking", set_id: r.set_id, position: t.index + 1, | |
| shown: shown.map(im => im.image_id), order, resets, rt: Math.round(performance.now() - t0) }); | |
| }; | |
| refresh(); | |
| } | |
| } | |
| /* ---------------------------------------------------------------- run */ | |
| const jsPsych = initJsPsych({ display_element: "jspsych-target", on_finish: () => submit() }); | |
| const P = { | |
| prolific_pid: jsPsych.data.getURLVariable("PROLIFIC_PID") || null, | |
| study_id: jsPsych.data.getURLVariable("STUDY_ID") || null, | |
| prolific_session_id: jsPsych.data.getURLVariable("SESSION_ID") || null, | |
| }; | |
| const target = document.getElementById("jspsych-target"); | |
| let SESSION = null; | |
| // Leaving or reloading mid-study loses every answer, so the browser asks first. | |
| const leaveGuard = e => { e.preventDefault(); e.returnValue = ""; }; | |
| async function submit() { | |
| const all = jsPsych.data.get(); | |
| const profile = all.filter({ trial_kind: "profile" }).values()[0] || {}; | |
| const payload = { | |
| session_id: SESSION.session_id, list_id: SESSION.list_id, | |
| participant: { ...P, english: profile.english, t2i: profile.t2i }, | |
| trials: all.filter({ trial_kind: "stimulus" }).values().map(({ trial_kind, trial_type, trial_index, time_elapsed, internal_node_id, ...d }) => d), | |
| rankings: all.filter({ trial_kind: "ranking" }).values().map(({ trial_kind, trial_type, trial_index, time_elapsed, internal_node_id, ...d }) => d), | |
| total_time_ms: Math.round(all.last(1).values()[0].time_elapsed), | |
| client: { user_agent: navigator.userAgent, screen: `${screen.width}x${screen.height}`, viewport: `${innerWidth}x${innerHeight}`, | |
| language: navigator.language, finished_at: new Date().toISOString() }, | |
| }; | |
| const show = html => { target.innerHTML = `<div class="jspsych-content-wrapper"><div class="jspsych-content">${progress({ rate: 1, rank: 1 })}<div class="card">${html}</div></div></div>`; }; | |
| show(`<div class="prompt"><span class="lbl">Saving</span><q>One moment…</q></div>`); | |
| try { | |
| const res = await fetch("/api/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); | |
| if (!res.ok) throw new Error(res.status); | |
| const out = await res.json(); | |
| removeEventListener("beforeunload", leaveGuard); | |
| // No automatic redirect: participants get time to read the feedback, then continue themselves. | |
| show(`<div class="prompt"><span class="lbl">Done</span><q>Thank you</q></div> | |
| <p>Your answers have been saved.</p> | |
| ${out.completion_code ? `<p>Your completion code:</p><div class="code">${esc(out.completion_code)}</div>` : ""} | |
| <div id="feedback" hidden></div> | |
| ${out.redirect ? `<div class="nav"><button class="b primary" id="toprolific">Back to Prolific</button></div>` | |
| : `<p class="muted">You can close this page.</p>`}`); | |
| if (out.redirect) document.getElementById("toprolific").onclick = () => { location.href = out.redirect; }; | |
| showFeedback(); | |
| } catch (e) { | |
| show(`<div class="prompt"><span class="lbl">Not saved yet</span><q>Your answers could not be sent</q></div> | |
| <p class="err">Check your connection, then try again. Please do not close this page.</p> | |
| <div class="nav"><button class="b primary" id="retry">Try again</button></div>`); | |
| document.getElementById("retry").onclick = submit; | |
| } | |
| } | |
| // Shown only after the answers are saved, so it cannot change them. Worded as a comparison, never as a score. | |
| async function showFeedback() { | |
| let fb = null; | |
| try { | |
| const res = await fetch("/api/feedback", { method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ session_id: SESSION.session_id, list_id: SESSION.list_id }) }); | |
| if (res.ok) fb = (await res.json()).feedback; | |
| } catch (e) {} | |
| const el = document.getElementById("feedback"); | |
| if (!fb || !el) return; | |
| const d = fb.mean_diff, pts = v => `${Math.abs(v).toFixed(1)} point${Math.abs(v).toFixed(1) === "1.0" ? "" : "s"}`; | |
| const tone = Math.abs(d) < 0.05 ? "On average, your ratings were the same as other participants’." | |
| : `On average, your ratings were ${pts(d)} ${d < 0 ? "lower" : "higher"} than other participants’.`; | |
| const m = fb.most_different; | |
| el.hidden = false; | |
| el.innerHTML = `<div class="fb"> | |
| <div class="prompt"><span class="lbl">Your answers and others’</span></div> | |
| <p>${tone}</p> | |
| <p>On ${fb.n_close} of the ${fb.n_compared} images we could compare, your rating was within 1 point of the others’ average.</p> | |
| ${m ? `<div class="fbcase"> | |
| <div class="stim"><img src="${src(m.file_name)}" alt=""></div> | |
| <div class="fbtext"> | |
| <span class="lbl">Where you differed most</span> | |
| <q>${esc(m.prompt)}</q> | |
| <p>You rated it <b>${m.rating}</b> out of 5. The other ${m.others_n} participants gave it <b>${m.others_mean.toFixed(1)}</b> on average.</p> | |
| <p class="muted">Neither answer is wrong: people read the same text differently, and these differences are what this study is about.</p> | |
| </div></div>` : ""} | |
| </div>`; | |
| } | |
| async function main() { | |
| try { | |
| const res = await fetch("/api/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(P) }); | |
| if (!res.ok) throw new Error(res.status); | |
| SESSION = await res.json(); | |
| } catch (e) { | |
| target.innerHTML = `<div class="jspsych-content-wrapper"><div class="jspsych-content"><div class="card"><div class="prompt"><span class="lbl">Error</span><q>The study could not start</q></div><p class="err">Reload the page in a minute. If it still fails, contact the researchers.</p></div></div></div>`; | |
| return; | |
| } | |
| const rankings = jsPsych.randomization.shuffle(SESSION.rankings || []); | |
| STEPS.rank = rankings.length > 0; | |
| const stims = jsPsych.randomization.shuffle(SESSION.stimuli); | |
| const practiceStim = { sample_id: "practice", prompt: "A butterfly on a flower", src: "/static/practice/butterfly.jpg", visual_style: "photo" }; | |
| const practiceSet = { set_id: "practice", prompt: "A butterfly on a flower", visual_style: "photo", | |
| images: ["butterfly", "macaw", "bluebird"].map(n => ({ image_id: n, src: `/static/practice/${n}.jpg` })) }; | |
| // Step 1 images load while the participant reads the consent and instructions, step 2 images on | |
| // the ranking instructions, in the order they will be shown. | |
| const rateImages = [practiceStim, ...stims].flatMap(stimURLs); | |
| const rankImages = [practiceSet, ...rankings].flatMap(r => r.images.map(imageURL)); | |
| addEventListener("beforeunload", leaveGuard); | |
| const timeline = [ | |
| { type: PagePlugin, button: "I agree, continue", on_start: () => preload(rateImages), html: ` | |
| <div class="prompt"><span class="lbl">Research study · about ${Math.max(3, Math.round(1.5 + stims.length * 0.3 + rankings.length * 0.5))} minutes</span><q>Does the image match the text?</q></div> | |
| <p>This study is part of academic research on how people judge images made by AI image generators. It has ${rankings.length ? "two parts" : "one part"}:</p> | |
| <ol> | |
| <li><b>Rate images</b>: you will see ${stims.length} short texts, each with an image, and rate how well each image matches its text.</li> | |
| ${rankings.length ? `<li><b>Rank images</b>: ${rankings.length} times, you will put a few images for one text in order, from the one that fits it best.</li>` : ""} | |
| </ol> | |
| <ul class="muted"> | |
| <li>Participation is voluntary and you can stop at any time by closing this page.</li> | |
| <li>We record your answers, response times and basic browser information. We do not collect your name or email.</li> | |
| <li>Anonymized answers may be published in a research paper.</li> | |
| ${SESSION.contact ? `<li>Questions about this study: ${esc(SESSION.contact)}</li>` : ""} | |
| </ul> | |
| <p>By continuing, you agree to take part.</p>` }, | |
| { type: ProfilePlugin }, | |
| { type: PagePlugin, button: "Begin", html: ` | |
| <div class="prompt"><span class="lbl">Instructions</span><q>How to answer</q></div> | |
| <div class="notice" role="note"> | |
| <p><b>Please note.</b> In this survey you will be shown text prompts and their corresponding images. Please judge to what degree each image matches the specifications of the prompt, not how beautiful or realistic it is.</p> | |
| <p><mark>This is your own judgement: there are no right or wrong answers.</mark></p> | |
| </div> | |
| <p>For each image, you answer one question: <b>How well does the image match the text?</b> Choose a score from 1 (does not match) to 5 (fully matches).</p> | |
| <!-- Question 2, turned off for now: | |
| <p>For each image, you answer two questions:</p> | |
| <ol> | |
| <li><b>How well does the image match the text?</b> Choose a score from 1 (does not match) to 5 (fully matches).</li> | |
| <li><b>Is there anything in the image that doesn’t match the text?</b> If yes, click (or tap) on each part that doesn’t match to select it; on most images the whole object or region is selected at once. If not, answer <b>No</b>.</li> | |
| </ol> --> | |
| <ul> | |
| <li>Positions such as <b>left</b>, <b>right</b>, <b>above</b> or <b>below</b> are meant from your point of view as you look at the image, not from the point of view of someone or something in it.</li> | |
| <li>Go with your first impression.</li> | |
| </ul> | |
| <p class="muted">The first image is a practice round.</p>`, progress: { on: 1, note: "Instructions" } }, | |
| { type: StimulusPlugin, practice: true, stim: practiceStim }, | |
| ...stims.map((s, i) => ({ type: StimulusPlugin, stim: s, index: i, total: stims.length, last: !rankings.length && i === stims.length - 1 })), | |
| ...(rankings.length ? [{ type: PagePlugin, button: "Start", progress: { on: 2, rate: 1, note: "Instructions" }, on_start: () => preload(rankImages), html: ` | |
| <div class="prompt"><span class="lbl">Last part</span><q>Which image fits the text best?</q></div> | |
| <div class="notice" role="note"> | |
| <p><b>Please note.</b> Rank the images by how well each one matches the text. When several images match it equally well, for example because the text leaves a detail open, put first the one you would rather get for this text.</p> | |
| <p>Some images show people. The ranking is never about the people themselves (their gender, ethnicity, religion, age, appearance or character). It is only about how the scene is shown, e.g., framing (a portrait, a close-up or a distant view), distance, background, composition, lighting, what else is in the picture, or details the text leaves open.</p> | |
| <p><mark>This is your own judgement: there are no right or wrong answers.</mark> Sometimes no order is objectively better: all the images may fit the text, or none of them. Then simply order them as you prefer.</p> | |
| </div> | |
| <p>You will now see several images for the same text.</p> | |
| <ul> | |
| <li>Tap the images in order, from the one that fits the text best to the one that fits it least.</li> | |
| <li>Go with your first impression.</li> | |
| </ul> | |
| <p class="muted">The first ranking is a practice round.</p>` }, | |
| { type: RankingPlugin, practice: true, set: practiceSet }] : []), | |
| ...rankings.map((r, i) => ({ type: RankingPlugin, set: r, index: i, total: rankings.length, last: i === rankings.length - 1 })), | |
| ]; | |
| await jsPsych.run(timeline); | |
| } | |
| main(); | |
| </script> | |
| </body> | |
| </html> | |