lterriel's picture
Update index.html
d2a4349 verified
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Medieval Illumination Detector</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
<script src="https://unpkg.com/mirador@latest/dist/mirador.min.js"></script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #fafafa;
color: #111;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
}
[v-cloak] {
display: none;
}
.app {
display: grid;
grid-template-columns: 370px 1fr;
min-height: 100vh;
height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: #fff;
border-right: 1px solid #e6e6e6;
}
.sidebar-header {
padding: 14px 16px 10px;
border-bottom: 1px solid #eee;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sidebar-header h1 {
min-width: 0;
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.sidebar-header p {
margin: 8px 0 0;
color: #666;
font-size: 12px;
}
.header-info {
margin-top: 8px;
}
.header-info p {
margin: 0;
}
.sidebar-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 12px 20px;
}
.citation-card {
margin-top: 10px;
padding: 9px;
background: #fafafa;
border: 1px solid #eee;
border-left: 3px solid #111;
border-radius: 10px;
font-size: 12px;
}
.citation-card-title {
margin-bottom: 6px;
color: #111;
font-size: 11px;
font-weight: 800;
letter-spacing: .03em;
text-transform: uppercase;
}
.citation-card p {
margin: 0;
color: #555;
}
.citation-card code {
display: block;
margin-top: 8px;
padding: 8px;
overflow-x: auto;
background: #fafafa;
border: 1px solid #eee;
border-radius: 8px;
color: #333;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 11px;
white-space: pre-wrap;
}
.citation-card a {
color: #111;
font-weight: 700;
text-decoration: none;
}
.citation-card a:hover {
text-decoration: underline;
}
.form-label {
display: block;
margin: 11px 0 6px;
color: #777;
font-size: 11px;
font-weight: 700;
letter-spacing: .03em;
text-transform: uppercase;
}
.field,
.select {
width: 100%;
padding: 7px 9px;
background: #fff;
border: 1px solid #ddd;
border-radius: 7px;
outline: none;
font-size: 13px;
}
textarea.field {
resize: vertical;
min-height: 70px;
font-family: inherit;
}
.field:focus,
.select:focus {
border-color: #111;
}
.row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.row-spread {
justify-content: space-between;
}
.flex-1 {
flex: 1 1 auto;
}
.mt-8 {
margin-top: 8px;
}
.mt-10 {
margin-top: 10px;
}
.small {
color: #666;
font-size: 12px;
}
.button {
padding: 7px 10px;
background: #fff;
color: #222;
border: 1px solid #ccc;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
font-weight: 700;
transition: .15s;
}
.button:hover:not(:disabled) {
background: #f5f5f5;
border-color: #999;
}
.button:disabled {
cursor: not-allowed;
opacity: .45;
}
.button-primary {
background: #111;
border-color: #111;
color: #fff;
}
.button-primary:hover:not(:disabled) {
background: #222;
border-color: #222;
}
.button-danger {
color: #b00;
border-color: #b00;
}
.button-danger:hover:not(:disabled) {
background: #fff3f3;
}
.button-link {
border: 0;
padding: 0;
background: transparent;
color: #111;
text-decoration: underline;
font-weight: 700;
cursor: pointer;
}
.status {
margin-top: 10px;
padding: 10px;
border-left: 3px solid #ddd;
border-radius: 6px;
background: #fafafa;
color: #555;
font-size: 12px;
word-break: break-word;
}
.status.loading {
border-left-color: #f0ad4e;
background: #fffcf5;
}
.status.error {
border-left-color: #b00;
background: #fff7f7;
color: #800;
}
.status.success {
border-left-color: #1a7f37;
background: #f5fff8;
color: #135d2a;
}
.tabs {
display: flex;
gap: 8px;
margin-top: 10px;
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 999px;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.tab.active {
background: #111;
border-color: #111;
color: #fff;
}
.tab-logo {
width: 18px;
height: 18px;
object-fit: contain;
}
.controls {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
}
.slider {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0 4px;
}
.slider-input {
width: 100%;
accent-color: #111;
}
.slider-value {
min-width: 44px;
text-align: right;
font-weight: 800;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 999px;
font-size: 12px;
}
.pill-label {
all: unset;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.collapsible {
margin-top: 10px;
border: 1px solid #eee;
border-radius: 12px;
background: #fafafa;
overflow: hidden;
}
.collapsible-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 9px 10px;
background: #fafafa;
border: 0;
cursor: pointer;
font-size: 12px;
font-weight: 800;
text-align: left;
}
.collapsible-body {
padding: 0 10px 10px;
border-top: 1px solid #eee;
}
.progress {
display: none;
margin: 10px 0 8px;
}
.progress.visible {
display: block;
}
.progress-bar {
width: 100%;
height: 4px;
overflow: hidden;
background: #eee;
border-radius: 999px;
}
.progress-fill {
height: 100%;
width: 0%;
background: #111;
transition: width .15s ease;
}
.progress-text {
margin-top: 4px;
color: #777;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.repo-card,
.project-card,
.help-box {
margin-top: 12px;
padding: 10px;
background: #fafafa;
border: 1px solid #eee;
border-radius: 12px;
font-size: 12px;
}
.repo-card a,
.project-card a {
color: #111;
font-weight: 700;
text-decoration: none;
}
.project-card {
display: flex;
align-items: flex-start;
gap: 10px;
}
.project-logo {
width: 76px;
height: 76px;
padding: 4px;
background: #fff;
border: 1px solid #eee;
border-radius: 10px;
object-fit: contain;
}
.project-logo-fallback {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: #111;
color: #fff;
border-radius: 10px;
font-size: 13px;
font-weight: 800;
}
.dropzone {
margin-top: 8px;
padding: 18px 14px;
background: #fafafa;
border: 1.5px dashed #ccc;
border-radius: 14px;
text-align: center;
transition: .15s;
}
.dropzone.dragover {
background: #f1f1f1;
border-color: #111;
}
.file-picker {
margin-top: 10px;
}
.file-input {
width: 100%;
font-size: 12px;
}
.viewer {
position: relative;
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
overflow: hidden;
background: #111;
}
.mirador-shell {
background: #111;
border-bottom: 1px solid #222;
}
.mirador-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
color: #eee;
font-size: 12px;
}
.mirador-topbar-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.mirador-title {
max-width: 56vw;
overflow: hidden;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.mirador-button {
background: transparent;
border-color: #444;
color: #eee;
}
.mirador-button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
border-color: #666;
}
.mirador-wrap {
height: 52vh;
}
.mirador-wrap.collapsed {
height: 0;
overflow: hidden;
}
#mirador {
position: relative !important;
width: 100%;
height: 100%;
overflow: hidden !important;
background: #111;
}
#mirador .mirador-viewer {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
}
#mirador .MuiDialog-root,
#mirador .MuiPopover-root,
#mirador .MuiModal-root,
#mirador .MuiBackdrop-root {
position: absolute !important;
}
#mirador .MuiPaper-root {
max-height: 100% !important;
}
.workspace {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: #fafafa;
}
.workspace-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
background: #fff;
border-bottom: 1px solid #eaeaea;
}
.facet-select {
min-width: 220px;
}
.workspace-content {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
overflow: hidden;
padding: 10px;
}
.hist {
flex: 0 0 auto;
padding: 10px;
background: #fff;
border: 1px solid #eee;
border-radius: 12px;
}
.hist-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.hist-title b {
font-size: 12px;
}
.hist-title span {
color: #666;
font-size: 11px;
}
.sparkline {
display: flex;
align-items: flex-end;
gap: 2px;
height: 40px;
}
.sparkbar {
flex: 1 1 0;
min-width: 0;
height: 8%;
background: #ddd;
border-radius: 2px 2px 0 0;
cursor: pointer;
transition: opacity .12s;
}
.sparkbar:hover {
opacity: .75;
}
.sparkbar.sel {
background: #111;
}
.sparkbar.ok {
background: #1a7f37;
}
.sparkbar.no {
background: #bbb;
}
.list-wrap {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
border-radius: 12px;
}
.list {
margin: 0;
padding: 0;
overflow: hidden;
list-style: none;
background: #fff;
border: 1px solid #eee;
border-radius: 12px;
}
.item {
display: grid;
grid-template-columns: 54px 1fr auto;
align-items: center;
gap: 10px;
padding: 10px;
border-bottom: 1px solid #f4f4f4;
cursor: pointer;
transition: background .1s;
}
.item:last-child {
border-bottom: none;
}
.item:hover {
background: #f8f8f8;
}
.item.active {
background: #eef6ff;
}
.thumb {
width: 54px;
height: 54px;
background: #eee;
border-radius: 10px;
object-fit: cover;
}
.meta {
min-width: 0;
}
.meta1 {
margin: 0;
overflow: hidden;
color: #111;
font-size: 13px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta2 {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 2px 0 0;
color: #777;
font-size: 11px;
}
.conf {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 170px;
color: #555;
font-size: 12px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.bar {
width: 64px;
height: 6px;
overflow: hidden;
background: #eee;
border-radius: 999px;
}
.fill {
height: 100%;
width: 0%;
background: #bbb;
transition: width .15s;
}
.fill.ok {
background: #1a7f37;
}
.editbox {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.checkbox-label {
all: unset;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
}
.button-compact {
padding: 5px 8px;
border-radius: 8px;
}
.image-right {
height: 100%;
overflow: auto;
padding: 14px;
background: #fafafa;
}
.image-card {
padding: 12px;
background: #fff;
border: 1px solid #eee;
border-radius: 14px;
}
.image-card-title {
font-weight: 800;
}
.image-grid {
display: grid;
grid-template-columns: minmax(240px, 1fr) minmax(220px, 320px);
align-items: start;
gap: 12px;
}
.img-big {
width: 100%;
background: #f0f0f0;
border: 1px solid #eee;
border-radius: 12px;
}
.prob-box {
padding: 10px;
background: #fff;
border: 1px solid #eee;
border-radius: 12px;
}
.hbar-row {
display: grid;
grid-template-columns: 1fr 120px 44px;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px dashed #eee;
font-variant-numeric: tabular-nums;
}
.hbar-row:last-child {
border-bottom: none;
}
.hbar-label {
overflow: hidden;
color: #222;
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.hbar {
height: 10px;
overflow: hidden;
background: #eee;
border-radius: 999px;
}
.hbar-fill {
height: 100%;
width: 0%;
background: #111;
transition: width .15s;
}
.hbar-val {
color: #333;
font-size: 12px;
font-weight: 800;
text-align: right;
}
.output {
margin-top: 12px;
white-space: pre-wrap;
font-size: 12px;
background: #fafafa;
border: 1px solid #eee;
padding: 10px;
border-radius: 10px;
}
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {
max-height: 55vh;
}
.mirador-title {
max-width: 80vw;
}
.image-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div id="app" class="app" v-cloak>
<aside class="sidebar">
<div class="sidebar-header">
<div class="header-title-row">
<h1>{{ appConfig.ui.title }}</h1>
<button class="button" type="button" @click="headerDescriptionOpen = !headerDescriptionOpen">
{{ headerDescriptionOpen ? t("hideInfo") : t("showInfo") }}
<span>{{ headerDescriptionOpen ? "▴" : "▾" }}</span>
</button>
</div>
<div v-show="headerDescriptionOpen" class="header-info">
<p>{{ appConfig.ui.description }}</p>
<div class="citation-card" v-if="appConfig.citation?.show">
<div class="citation-card-title">
{{ appConfig.citation.title || t("citationTitle") }}
</div>
<p>
{{ appConfig.citation.text || t("citationText") }}
<template v-if="appConfig.citation.url">
<br>
<a :href="appConfig.citation.url" target="_blank" rel="noopener">
{{ appConfig.citation.linkLabel || t("citationLinkLabel") }}
</a>
</template>
</p>
<code v-if="appConfig.citation.short">{{ appConfig.citation.short }}</code>
</div>
</div>
</div>
<div class="sidebar-content">
<div class="sidebar-content">
<div v-if="appConfig.ui.showArtefactsInput">
<label class="form-label">{{ t("artefactsLabel") }}</label>
<input class="field" type="text" v-model="artefactsBaseUrl" :placeholder="t('artefactsPlaceholder')"/>
<div class="small mt-8">{{ t("artefactsHelp") }}</div>
</div>
<div class="repo-card" v-if="appConfig.mode === 'online' && HF_REPO_URL">
🤗 <a :href="HF_REPO_URL" target="_blank" rel="noopener">{{ HF_REPO_LABEL }}</a>
</div>
<label class="form-label">{{ t("runsLabel") }}</label>
<div class="row">
<select class="select flex-1" v-model="selectedRun">
<option v-for="run in runOptions" :key="run.id" :value="run.id">
{{ run.label || run.id }}
</option>
</select>
<button class="button" @click="refreshRuns" :title="t('refreshRuns')"></button>
</div>
<div class="row mt-8">
<button class="button button-primary" @click="loadRun" :disabled="!selectedRun || isBusy">
{{ t("loadModel") }}
</button>
<button class="button" @click="clearAll" :disabled="isBusy">
{{ t("reset") }}
</button>
</div>
<div class="status" :class="statusType" v-if="statusMsg">{{ statusMsg }}</div>
<div class="tabs" role="tablist" :aria-label="t('tabsAriaLabel')">
<button class="tab" :class="{active: tab === 'iiif'}" type="button" @click="tab = 'iiif'">
<img class="tab-logo" :src="IIIF_LOGO_URL" alt="IIIF"> {{ t("iiifTab") }}
</button>
<button class="tab" :class="{active: tab === 'image'}" type="button" @click="tab = 'image'">
🖼️ {{ t("imageTab") }}
</button>
</div>
<section v-if="tab === 'iiif'" class="controls">
<label class="form-label">{{ t("manifestUrlLabel") }}</label>
<input class="field" type="url" v-model="manifestUrl" :placeholder="t('manifestUrlPlaceholder')"/>
<div class="row mt-8">
<button class="button button-primary" @click="loadManifest"
:disabled="!modelReady || !manifestUrl || isBusy">
{{ t("loadManifest") }}
</button>
<button class="button button-primary" @click="classifyAll"
:disabled="!modelReady || canvases.length === 0 || isBusy || isRunning">
{{ t("classify") }}
</button>
<button class="button button-danger" @click="stop" :disabled="!isRunning">
{{ t("stop") }}
</button>
</div>
<label class="form-label">{{ t("thresholdLabel") }}</label>
<div class="slider">
<input
class="slider-input"
type="range"
min="0"
max="100"
v-model.number="thresholdPct"
@input="applySelection(true, 'filter')"
/>
<div class="slider-value">{{ thresholdPct }}%</div>
</div>
<div class="row row-spread mt-8">
<div class="small">
{{ t("optimalThresholdLabel") }}: <strong>{{ optimalThresholdPct }}%</strong>
</div>
<button
class="button"
type="button"
@click="resetThresholdToOptimal"
:disabled="!modelReady || thresholdPct === optimalThresholdPct"
>
{{ t("resetOptimalThreshold") }}
</button>
</div>
<label class="form-label">{{ t("parallelLabel") }}</label>
<div class="slider">
<input class="slider-input" type="range" min="1" max="20" step="1"
v-model.number="classificationConcurrency" :disabled="isRunning"/>
<div class="slider-value">{{ classificationConcurrency }}</div>
</div>
<div class="small">{{ t("parallelHelp") }}</div>
<div class="collapsible">
<button class="collapsible-header" type="button" @click="heuristicPanelOpen = !heuristicPanelOpen">
<span>{{ t("heuristicSettingsLabel") }}</span>
<span>{{ heuristicPanelOpen ? "▴" : "▾" }}</span>
</button>
<div class="collapsible-body" v-show="heuristicPanelOpen">
<label class="pill pill-label mt-10">
<input type="checkbox" v-model="heuristicEnabled">
{{ t("heuristicEnabled") }}
</label>
<textarea
class="field mt-8"
rows="4"
v-model="heuristicKeywordsText"
:placeholder="t('heuristicKeywordsPlaceholder')"
></textarea>
<div class="small mt-8">{{ t("heuristicKeywordsHelp") }}</div>
</div>
</div>
<div class="progress" :class="{visible: progressVisible}">
<div class="progress-bar">
<div class="progress-fill" :style="progressStyle"></div>
</div>
<div class="progress-text">{{ progressText }}</div>
</div>
<div class="small mt-10">
{{ t("exportFacetHelp") }}
</div>
<div class="row mt-8">
<button class="button" @click="exportSelection" :disabled="selectionIds.size === 0">
{{ t("exportFacetSelection") }} — {{ activeFacetLabel }}
({{ selectionIds.size }} {{ t("pages") }})
</button>
</div>
<div class="project-card" v-if="appConfig.project?.show">
<img
v-if="projectLogoOk && appConfig.project.logoUrl"
class="project-logo"
:src="PROJECT_LOGO_URL"
:alt="appConfig.project.name || t('projectLogoAlt')"
@error="projectLogoOk = false"
/>
<div v-else class="project-logo-fallback">{{ projectInitials }}</div>
<div>
<p>
{{ appConfig.project.description }}
<template v-if="appConfig.project.url">
<br>
<a :href="appConfig.project.url" target="_blank" rel="noopener">
{{ appConfig.project.name }}
</a>
</template>
<template v-if="appConfig.project.institution">
<br>
<a v-if="appConfig.project.institutionUrl"
:href="appConfig.project.institutionUrl"
target="_blank"
rel="noopener">
{{ appConfig.project.institution }}
</a>
<span v-else>{{ appConfig.project.institution }}</span>
</template>
</p>
</div>
</div>
</section>
<section v-if="tab === 'image'" class="controls">
<label class="form-label">{{ t("localImageLabel") }}</label>
<div
class="dropzone"
:class="{dragover: dragOverImage}"
@dragover.prevent="dragOverImage = true"
@dragleave.prevent="dragOverImage = false"
@drop.prevent="onImageDrop"
>
<strong>{{ t("dropImage") }}</strong>
<span>{{ t("browseImage") }}</span>
<div class="file-picker">
<input class="file-input" type="file" accept="image/*" @change="onFile"
:disabled="!modelReady || isBusy"/>
</div>
</div>
<label class="form-label">{{ t("thresholdLabel") }}</label>
<div class="slider">
<input class="slider-input" type="range" min="0" max="100" v-model.number="thresholdPctImage"/>
<div class="slider-value">{{ thresholdPctImage }}%</div>
</div>
<div class="row mt-10">
<button class="button button-primary" @click="predictImage"
:disabled="!modelReady || !imageFile || isBusy">
{{ t("predict") }}
</button>
<button class="button" @click="clearImage" :disabled="isBusy">
{{ t("clear") }}
</button>
</div>
<div class="small mt-10">{{ t("imageHelp") }}</div>
</section>
<div class="help-box" v-if="appConfig.ui.showHelp">
<strong>{{ t("helpTitle") }}</strong><br/>
<span>{{ t("helpStep1") }}</span><br/>
<span>{{ t("helpStep2") }}</span><br/>
<span>{{ t("helpStep3") }}</span>
</div>
</div>
</aside>
<main class="viewer">
<template v-if="tab === 'iiif'">
<section class="mirador-shell">
<div class="mirador-topbar">
<div class="mirador-topbar-left">
<button class="button mirador-button" @click="miradorCollapsed = !miradorCollapsed"
:disabled="!manifest">
{{ miradorCollapsed ? t("expandMirador") : t("collapseMirador") }}
</button>
<div class="mirador-title">{{ currentTitle }}</div>
</div>
<div class="row">
<button class="button mirador-button" @click="openFirstSelected"
:disabled="selection.length === 0">
{{ t("openSelection") }}
</button>
<button class="button mirador-button" @click="goNextSelected(1)"
:disabled="selection.length === 0">
{{ t("next") }}
</button>
<button class="button mirador-button" @click="goNextSelected(-1)"
:disabled="selection.length === 0">
{{ t("previous") }}
</button>
</div>
</div>
<div class="mirador-wrap" :class="{collapsed: miradorCollapsed}">
<div id="mirador"></div>
</div>
</section>
<section class="workspace">
<div class="workspace-header">
<span class="pill">
<b>{{ t("selection") }}</b>
{{ selection.length }} / {{ canvases.length }}
</span>
<span class="pill">
<b>{{ t("threshold") }}</b>
{{ thresholdPct }}%
</span>
<span class="pill">
{{ t("facetLabel") }}:
<select class="select facet-select" v-model="facet" @change="applySelection(false, 'filter')">
<option value="all">{{ t("allFacet") }}</option>
<option value="pos">{{ t("positiveFacet") }}</option>
<option value="neg">{{ t("negativeFacet") }}</option>
<option value="overridden">{{ t("overriddenFacet") }}</option>
<option value="pending">{{ t("pendingFacet") }}</option>
<option value="errors">{{ t("errorsFacet") }}</option>
</select>
</span>
<label class="pill pill-label">
<input type="checkbox" v-model="editMode" @change="applySelection(true)">
{{ t("editMode") }}
</label>
<span class="pill" v-if="selection.length > 0">
<b>{{ t("current") }}</b>
#{{ currentSelectedPos + 1 }} / {{ selection.length }}
</span>
</div>
<div class="workspace-content">
<div class="hist" v-if="histItems.length > 0">
<div class="hist-title">
<b>{{ t("histogramTitle") }}</b>
<span>{{ t("histogramHelp") }}</span>
</div>
<div class="sparkline">
<div
v-for="histItem in histItems"
:key="histItem.id"
class="sparkbar"
:class="sparkBarClass(histItem)"
:style="sparkBarStyle(histItem.id)"
:title="sparkTitle(histItem)"
@click="selectCanvas(histItem)"
></div>
</div>
</div>
<div class="hist" v-else>
<div class="hist-title">
<b>{{ t("emptyHistogramTitle") }}</b>
<span>{{ t("emptyHistogram") }}</span>
</div>
</div>
<div class="list-wrap" ref="listWrap">
<ul class="list">
<li
class="item"
v-for="canvas in selection"
:key="canvas.id"
:class="{active: canvas._index === currentIndex}"
@click="selectCanvas(canvas)"
>
<img class="thumb" :src="canvas.thumb || ''" alt="" @error="hideImg"/>
<div class="meta">
<p class="meta1">{{ canvas.label }}</p>
<p class="meta2">
<span>{{ itemStatusText(canvas.id) }}</span>
<span v-if="overrides.has(canvas.id)">{{ t("override") }}</span>
</p>
</div>
<div class="conf">
<div v-if="hasUsableResult(canvas.id)" class="bar">
<div class="fill" :class="{ok: finalChecked(canvas.id)}"
:style="probabilityFillStyle(canvas.id)"></div>
</div>
<span v-if="hasUsableResult(canvas.id)">
{{ Math.round(getPositiveScore(canvas.id) * 100) }}%
</span>
<div class="editbox" v-if="editMode">
<label class="checkbox-label" @click.stop>
<input
type="checkbox"
:checked="finalChecked(canvas.id)"
@click.stop
@change.stop="setOverride(canvas.id, $event.target.checked)"
>
{{ positiveLabelShort }}
</label>
<button class="button button-compact" @click.stop="clearOverride(canvas.id)">
{{ t("resetOverride") }}
</button>
</div>
</div>
</li>
</ul>
</div>
<div class="small">{{ t("heuristicHelp") }}</div>
</div>
</section>
</template>
<template v-else>
<div class="image-right">
<div class="image-card">
<div class="row row-spread mt-10">
<div class="image-card-title">{{ t("imageResult") }}</div>
<div class="small" v-if="imageResult">
<b>{{ t("predictionLabel") }}</b>: {{ imageResult.pred }}
· <b>{{ scoreLabel }}</b>: {{ (imageResult.p_positive * 100).toFixed(1) }}%
</div>
</div>
<div v-if="imagePreviewUrl" class="image-grid mt-10">
<img class="img-big" :src="imagePreviewUrl" :alt="t('imagePreviewAlt')"/>
<div class="prob-box" v-if="imageResult">
<div class="hbar-row" v-for="(probability, index) in imageResult.probs" :key="index">
<div class="hbar-label">
{{ imageResult.labels[index] ?? `${t("classLabelPrefix")}_${index}` }}
</div>
<div class="hbar">
<div class="hbar-fill" :style="horizontalProbabilityStyle(probability)"></div>
</div>
<div class="hbar-val">{{ (probability * 100).toFixed(1) }}%</div>
</div>
</div>
<div class="prob-box" v-else>
<b>{{ t("probabilities") }}</b>
<div class="small mt-8">{{ t("noImageResult") }}</div>
</div>
</div>
<div v-else class="small mt-10">{{ t("loadImageHelp") }}</div>
<pre v-if="imageOutput" class="output">{{ imageOutput }}</pre>
</div>
</div>
</template>
</main>
</div>
<script type="module">
const {createApp} = Vue;
/**
* Default application configuration.
*
* This object is intentionally generic. Domain-specific wording, labels,
* model runs, project metadata, and heuristic keywords should live in
* app.config.json.
*/
const DEFAULT_CONFIG = {
mode: "local",
ui: {
title: "IIIF Image Classifier",
description: "Load an image classifier, run it on IIIF manifests or local images, and export the selected canvases as a new IIIF manifest.",
descriptionCollapsedByDefault: true,
showHelp: true,
showArtefactsInput: true,
texts: {
showInfo: "Info",
hideInfo: "Hide",
artefactsLabel: "Artifacts web folder",
artefactsPlaceholder: "./Artefacts",
artefactsHelp: "Start a local HTTP server. Browsers cannot read arbitrary local file paths.",
runsLabel: "Available models",
refreshRuns: "Refresh model list",
loadModel: "Load model",
reset: "Reset",
tabsAriaLabel: "Input mode",
iiifTab: "IIIF Manifest",
imageTab: "Image",
manifestUrlLabel: "IIIF manifest URL",
manifestUrlPlaceholder: "https://.../manifest.json",
loadManifest: "Load manifest",
classify: "Classify",
stop: "Stop",
thresholdLabel: "Positive-class threshold",
threshold: "Threshold",
resetOptimalThreshold: "Reset optimal threshold",
exportFacetSelection: "Export current facet selection",
exportFacetHelp: "The exported manifest contains the canvases currently shown by the active facet. Switch facet to export another selection.",
optimalThresholdLabel: "Model optimal threshold",
parallelLabel: "Parallelization",
parallelHelp: "Number of canvases classified in parallel.",
heuristicSettingsLabel: "Negative heuristic",
heuristicEnabled: "Enable forced negative labels",
heuristicKeywordsPlaceholder: "One keyword per line",
heuristicKeywordsHelp: "If a canvas label contains one of these keywords, it is classified as negative without model inference.",
heuristicSkipped: "heuristic negative",
heuristicHelp: "Negative heuristic can force selected labels to the negative class before inference.",
exportManifest: "Export filtered IIIF manifest",
pages: "pages",
facetLabel: "Facet",
allFacet: "All",
positiveFacet: "Positive",
negativeFacet: "Negative",
overriddenFacet: "Corrected",
pendingFacet: "Pending",
errorsFacet: "Errors",
editMode: "Correction mode",
selection: "Selection",
current: "Current",
histogramTitle: "Positive-class score histogram",
histogramHelp: "Click a bar to open",
emptyHistogramTitle: "Empty histogram",
emptyHistogram: "No canvas in the selection",
localImageLabel: "Local image",
dropImage: "Drag and drop an image here",
browseImage: "or use the file picker below",
predict: "Predict",
clear: "Clear",
imageHelp: "The prediction and probability bars are displayed on the right.",
imageResult: "Image result",
predictionLabel: "Prediction",
probabilities: "Probabilities",
runPrediction: "Run prediction",
noImageResult: "No result yet.",
loadImageHelp: "Load an image from the Image tab, then click Predict.",
imagePreviewAlt: "Image preview",
classLabelPrefix: "class",
projectLogoAlt: "Project logo",
expandMirador: "▸ Expand Mirador",
collapseMirador: "▾ Collapse Mirador",
openSelection: "Open selection",
next: "Next",
previous: "Previous",
override: "override",
positiveShort: "positive",
resetOverride: "Reset",
scoreLabel: "p(positive)",
statusReady: "Ready. Select a model, then load it.",
statusLoadingRuns: "Loading model list…",
statusOnlineRuns: "Online models",
statusLocalRuns: "Local models found",
statusRunsError: "Unable to load model list.",
statusOnlineRunMissing: "Online model entry not found in app.config.json.",
statusLoadingModel: "Loading ONNX model and configuration…",
statusModelLoaded: "Model loaded",
statusLoadRunError: "Model loading error",
statusResetOk: "Reset complete.",
statusLoadModelFirst: "Load a model first.",
statusLoadingManifest: "Loading manifest…",
statusManifestLoaded: "Manifest loaded",
statusManifestError: "Manifest error",
statusLoadManifestFirst: "Load a manifest first.",
statusLoadModelBeforeImage: "Load a model before adding an image.",
statusDroppedFileNotImage: "The dropped file is not an image.",
statusSelectedFileNotImage: "The selected file is not an image.",
statusClassificationRunning: "Classification running…",
parallelSuffix: "in parallel",
statusStopped: "Stopped",
statusDone: "Done",
positivesAtThreshold: "positives at threshold",
statusStopRequested: "Stop requested.",
statusPending: "Pending",
statusError: "Error",
statusProcessing: "Processing…",
statusExportOk: "IIIF v3 manifest exported",
statusImageLoaded: "Image loaded",
statusImageInference: "Running image inference…",
statusImageDone: "Image processed",
statusPredictionError: "Prediction error",
errorModelNotFound: "Unable to find an ONNX model.",
errorNoCanvasImageUrl: "No image URL found for this canvas.",
helpTitle: "Local help",
helpStep1: "1) Place index.html next to app.config.json.",
helpStep2: "2) Start a server: python -m http.server 8000.",
helpStep3: "3) Open http://localhost:8000 and set the artifacts web folder.",
citationTitle: "How to cite",
citationText: "If you use this application or its outputs, please cite the associated software.",
citationLinkLabel: "Citation metadata"
}
},
project: {
show: false,
name: "",
description: "",
url: "",
institution: "",
institutionUrl: "",
logoUrl: ""
},
citation: {
show: false,
title: "How to cite",
text: "If you use this application or its outputs, please cite the associated software.",
short: "",
url: "",
linkLabel: "Citation metadata"
},
assets: {
iiifLogoUrl: "./assets/images/iiif-logo.png",
projectLogoUrl: ""
},
labels: {
positive: "positive",
negative: "negative",
positiveAliases: ["positive", "1"],
negativeAliases: ["negative", "0"]
},
heuristic: {
enabled: true,
collapsedByDefault: true,
keywordsForceNegative: []
},
local: {
defaultArtefactsBaseUrl: "./Artefacts"
},
online: {
huggingFaceRepoLabel: "",
huggingFaceRepoURL: "",
runs: []
}
};
/**
* Deeply merges plain configuration objects.
*
* Arrays are replaced, not concatenated. This makes app.config.json
* predictable for labels, aliases, model runs, and heuristic keywords.
*
* @param {object} base - Base object.
* @param {object} override - Override object.
* @returns {object} Merged object.
*/
function deepMerge(base, override) {
if (!override || typeof override !== "object") return structuredClone(base);
const output = structuredClone(base);
for (const [key, value] of Object.entries(override)) {
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
output[key] &&
typeof output[key] === "object" &&
!Array.isArray(output[key])
) {
output[key] = deepMerge(output[key], value);
} else {
output[key] = value;
}
}
return output;
}
/**
* Loads app.config.json and merges it with DEFAULT_CONFIG.
*
* @returns {Promise<object>} Complete application configuration.
*/
async function loadAppConfig() {
try {
const response = await fetch("./app.config.json", {
cache: "no-store",
headers: {"Cache-Control": "no-cache", "Pragma": "no-cache"}
});
if (!response.ok) return DEFAULT_CONFIG;
const userConfig = await response.json();
return deepMerge(DEFAULT_CONFIG, userConfig);
} catch (error) {
console.warn("Unable to load app.config.json. Using DEFAULT_CONFIG.", error);
return DEFAULT_CONFIG;
}
}
/**
* Adds a cache-busting query parameter to small config JSON URLs.
*
* @param {string} url - URL to modify.
* @returns {string} URL with cache-busting timestamp.
*/
function withCacheBuster(url) {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}_=${Date.now()}`;
}
/**
* Normalizes text for case-insensitive and accent-insensitive matching.
*
* @param {string} value - Raw text.
* @returns {string} Normalized text.
*/
function normalizeText(value) {
if (!value) return "";
return String(value)
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Clamps a number inside a range.
*
* @param {number} value - Input number.
* @param {number} lowerBound - Lower bound.
* @param {number} upperBound - Upper bound.
* @returns {number} Clamped number.
*/
function clamp(value, lowerBound, upperBound) {
return Math.max(lowerBound, Math.min(upperBound, value));
}
/**
* Converts logits to probabilities with softmax.
*
* @param {number[]} values - Model logits.
* @returns {number[]} Probabilities.
*/
function softmax(values) {
const maxValue = Math.max(...values);
const exps = values.map(value => Math.exp(value - maxValue));
const sum = exps.reduce((acc, value) => acc + value, 0);
return exps.map(value => value / sum);
}
/**
* Fetches JSON and throws explicit errors.
*
* Important CORS note:
* Do not send custom headers such as Cache-Control or Pragma here.
* On external IIIF manifests, those headers turn a simple GET into a
* CORS preflight request. Some IIIF servers answer preflight with redirects
* or without the expected CORS headers, which makes the browser block the
* request before the manifest can be fetched.
*
* Cache-busting is handled by adding a query parameter only for local/model
* configuration files, not by custom HTTP headers.
*
* @param {string} url - JSON URL.
* @param {AbortSignal} [signal] - Optional abort signal.
* @param {object} [options] - Fetch options.
* @param {boolean} [options.noStore=false] - Use browser no-store cache mode.
* @returns {Promise<object>} Parsed JSON.
*/
async function fetchJsonOrThrow(url, signal, options = {}) {
const fetchOptions = {signal};
if (options.noStore) {
fetchOptions.cache = "no-store";
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`HTTP ${response.status} — ${url}\n${text.slice(0, 200)}`);
}
return await response.json();
}
/**
* Resizes an image to a square without preserving aspect ratio.
*
* This matches torchvision.transforms.Resize((size, size)).
*
* @param {HTMLImageElement} image - Source image.
* @param {number} size - Output square size.
* @returns {{imageData: ImageData, cropSize: number}} ImageData and output size.
*/
function resizeSquare(image, size) {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d", {willReadFrequently: true});
context.drawImage(image, 0, 0, size, size);
return {
imageData: context.getImageData(0, 0, size, size),
cropSize: size
};
}
/**
* Resizes an image preserving aspect ratio and pads the remaining area.
*
* @param {HTMLImageElement} image - Source image.
* @param {number} size - Output square size.
* @param {number} fill - RGB fill value.
* @returns {{imageData: ImageData, cropSize: number}} ImageData and output size.
*/
function resizeWithPadding(image, size, fill = 255) {
const originalWidth = image.naturalWidth || image.width;
const originalHeight = image.naturalHeight || image.height;
const scale = Math.min(size / originalWidth, size / originalHeight);
const newWidth = Math.round(originalWidth * scale);
const newHeight = Math.round(originalHeight * scale);
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d", {willReadFrequently: true});
context.fillStyle = `rgb(${fill}, ${fill}, ${fill})`;
context.fillRect(0, 0, size, size);
const x = Math.floor((size - newWidth) / 2);
const y = Math.floor((size - newHeight) / 2);
context.drawImage(image, x, y, newWidth, newHeight);
return {
imageData: context.getImageData(0, 0, size, size),
cropSize: size
};
}
/**
* Resizes the shorter edge and center-crops a square.
*
* @param {HTMLImageElement} image - Source image.
* @param {number} resizeSize - Target shorter edge before crop.
* @param {number} cropSize - Output crop size.
* @returns {{imageData: ImageData, cropSize: number}} ImageData and output size.
*/
function resizeShorterEdgeAndCenterCrop(image, resizeSize, cropSize) {
const originalWidth = image.naturalWidth || image.width;
const originalHeight = image.naturalHeight || image.height;
let newWidth;
let newHeight;
if (originalWidth < originalHeight) {
newWidth = resizeSize;
newHeight = Math.round(originalHeight * (resizeSize / originalWidth));
} else {
newHeight = resizeSize;
newWidth = Math.round(originalWidth * (resizeSize / originalHeight));
}
const canvas = document.createElement("canvas");
canvas.width = newWidth;
canvas.height = newHeight;
const context = canvas.getContext("2d", {willReadFrequently: true});
context.drawImage(image, 0, 0, newWidth, newHeight);
const x0 = Math.floor((newWidth - cropSize) / 2);
const y0 = Math.floor((newHeight - cropSize) / 2);
const sx = clamp(x0, 0, Math.max(0, newWidth - cropSize));
const sy = clamp(y0, 0, Math.max(0, newHeight - cropSize));
return {
imageData: context.getImageData(sx, sy, cropSize, cropSize),
cropSize
};
}
/**
* Applies the preprocessing mode declared in preprocess.json/inference_config.json.
*
* Supported preprocess_mode values:
* - resize_square: direct square resize.
* - padding: aspect-ratio preserving resize with padding.
* - center_crop: shorter-edge resize followed by center crop.
*
* @param {HTMLImageElement} image - Source image.
* @param {object} spec - Preprocessing specification.
* @returns {{imageData: ImageData, cropSize: number}} ImageData and output size.
*/
function preprocessImageData(image, spec) {
const size = Number(spec.img_size) || 224;
const resizeSize = Number(spec.resize_size) || size;
const mode = spec.preprocess_mode || "resize_square";
if (mode === "center_crop") return resizeShorterEdgeAndCenterCrop(image, resizeSize, size);
if (mode === "padding") return resizeWithPadding(image, size);
return resizeSquare(image, size);
}
/**
* Converts an HTML image element to an ONNX Runtime tensor.
*
* Output shape is [1, 3, H, W], normalized with the model mean/std.
*
* @param {HTMLImageElement} image - Image element.
* @param {object} spec - Preprocessing specification.
* @returns {ort.Tensor} ONNX input tensor.
*/
function imageToTensor(image, spec) {
const cropSize = Number(spec.img_size) || 224;
const {imageData} = preprocessImageData(image, spec);
const rgba = imageData.data;
const mean = spec.mean || [0.485, 0.456, 0.406];
const std = spec.std || [0.229, 0.224, 0.225];
const floatData = new Float32Array(1 * 3 * cropSize * cropSize);
let pixelIndex = 0;
for (let y = 0; y < cropSize; y++) {
for (let x = 0; x < cropSize; x++) {
const rgbaOffset = (y * cropSize + x) * 4;
const r = rgba[rgbaOffset] / 255.0;
const g = rgba[rgbaOffset + 1] / 255.0;
const b = rgba[rgbaOffset + 2] / 255.0;
floatData[0 * cropSize * cropSize + pixelIndex] = (r - mean[0]) / std[0];
floatData[1 * cropSize * cropSize + pixelIndex] = (g - mean[1]) / std[1];
floatData[2 * cropSize * cropSize + pixelIndex] = (b - mean[2]) / std[2];
pixelIndex++;
}
}
return new ort.Tensor("float32", floatData, [1, 3, cropSize, cropSize]);
}
/**
* Detects the IIIF Presentation API version.
*
* @param {object} manifest - IIIF manifest.
* @returns {number} IIIF version, 2 or 3.
*/
function detectManifestVersion(manifest) {
if (manifest.sequences) return 2;
if (manifest.items) return 3;
const context = manifest["@context"];
if (typeof context === "string" && context.includes("presentation/3")) return 3;
if (Array.isArray(context) && context.some(item => typeof item === "string" && item.includes("presentation/3"))) return 3;
return 2;
}
/**
* Extracts a string label from IIIF v2/v3 label formats.
*
* @param {string|object|null} label - IIIF label.
* @param {number} index - Canvas index.
* @returns {string} Human-readable label.
*/
function getLabel(label, index) {
if (!label) return `Canvas ${index + 1}`;
if (typeof label === "string") return label;
if (typeof label === "object") {
const values = label.fr || label.en || label.none || Object.values(label)[0];
if (Array.isArray(values)) return values[0];
return values || `Canvas ${index + 1}`;
}
return `Canvas ${index + 1}`;
}
/**
* Extracts normalized canvas records from a IIIF manifest.
*
* @param {object} manifest - IIIF manifest.
* @returns {object[]} Normalized canvases.
*/
function extractCanvases(manifest) {
const version = detectManifestVersion(manifest);
const output = [];
if (version === 2) {
const canvases = manifest.sequences?.[0]?.canvases || [];
canvases.forEach((canvas, index) => output.push(parseCanvasV2(canvas, index)));
return output;
}
const items = manifest.items || [];
items.forEach((item, index) => {
if (item.type === "Canvas") output.push(parseCanvasV3(item, index));
});
return output;
}
/**
* Parses a IIIF Presentation API v2 canvas.
*
* @param {object} canvas - IIIF v2 canvas.
* @param {number} index - Canvas index.
* @returns {object} Normalized canvas.
*/
function parseCanvasV2(canvas, index) {
let imageUrl = null;
let serviceUrl = null;
let imgW = null;
let imgH = null;
let format = null;
const resource = canvas.images?.[0]?.resource;
if (resource) {
imageUrl = resource["@id"] || resource.id || null;
imgW = resource.width ?? null;
imgH = resource.height ?? null;
format = resource.format ?? null;
const service = resource.service;
serviceUrl = service?.["@id"] || service?.id || null;
}
const id = canvas["@id"] || canvas.id || `idx-${index + 1}`;
const label = getLabel(canvas.label, index);
const thumb = getThumbnailUrl(canvas.thumbnail, serviceUrl);
return {
id,
label,
index,
width: canvas.width ?? imgW ?? null,
height: canvas.height ?? imgH ?? null,
imageUrl,
serviceUrl,
thumb,
imgW,
imgH,
format,
_index: index
};
}
/**
* Parses a IIIF Presentation API v3 canvas.
*
* @param {object} canvas - IIIF v3 canvas.
* @param {number} index - Canvas index.
* @returns {object} Normalized canvas.
*/
function parseCanvasV3(canvas, index) {
let imageUrl = null;
let serviceUrl = null;
let imgW = null;
let imgH = null;
let format = null;
for (const annotationPage of canvas.items || []) {
for (const annotation of annotationPage.items || []) {
if (annotation.motivation !== "painting") continue;
const body = annotation.body;
if (!body) continue;
imageUrl = body.id || body["@id"] || null;
imgW = body.width ?? null;
imgH = body.height ?? null;
format = body.format ?? null;
const services = body.service || [];
const service = Array.isArray(services) ? services[0] : services;
serviceUrl = service?.["@id"] || service?.id || null;
}
}
const id = canvas.id || canvas["@id"] || `idx-${index + 1}`;
const label = getLabel(canvas.label, index);
const thumb = getThumbnailUrl(canvas.thumbnail, serviceUrl);
return {
id,
label,
index,
width: canvas.width ?? imgW ?? null,
height: canvas.height ?? imgH ?? null,
imageUrl,
serviceUrl,
thumb,
imgW,
imgH,
format,
_index: index
};
}
/**
* Extracts or builds a thumbnail URL.
*
* @param {string|object|Array|null} thumbnail - IIIF thumbnail.
* @param {string|null} serviceUrl - IIIF Image API service URL.
* @returns {string|null} Thumbnail URL.
*/
function getThumbnailUrl(thumbnail, serviceUrl) {
if (thumbnail) {
const value = Array.isArray(thumbnail) ? thumbnail[0] : thumbnail;
if (typeof value === "string") return value;
return value.id || value["@id"] || null;
}
if (serviceUrl) return `${serviceUrl}/full/!320,320/0/default.jpg`;
return null;
}
/**
* Chooses the image URL used for classification.
*
* @param {object} canvas - Normalized canvas.
* @param {number} target - Target width requested from IIIF Image API.
* @returns {Promise<string|null>} Image URL.
*/
async function bestImageUrlForInference(canvas, target = 250) {
if (canvas.serviceUrl) return `${canvas.serviceUrl}/full/${target},/0/default.jpg`;
return canvas.imageUrl || canvas.thumb;
}
/**
* Loads an artifacts index.json and returns run IDs.
*
* @param {string} baseUrl - Artifacts base URL.
* @param {AbortSignal} [signal] - Optional abort signal.
* @returns {Promise<string[]>} Run IDs.
*/
async function loadRunsIndex(baseUrl, signal) {
const indexUrl = withCacheBuster(`${baseUrl.replace(/\/$/, "")}/index.json`);
const indexJson = await fetchJsonOrThrow(indexUrl, signal, {noStore: true});
const runs = Array.isArray(indexJson) ? indexJson : (indexJson.runs || []);
if (!Array.isArray(runs)) {
throw new Error("Invalid index.json. Expected { runs: [...] } or [...].");
}
return runs;
}
createApp({
data() {
return {
appConfig: DEFAULT_CONFIG,
artefactsBaseUrl: DEFAULT_CONFIG.local.defaultArtefactsBaseUrl,
HF_REPO_URL: DEFAULT_CONFIG.online.huggingFaceRepoURL,
HF_REPO_LABEL: DEFAULT_CONFIG.online.huggingFaceRepoLabel,
IIIF_LOGO_URL: DEFAULT_CONFIG.assets.iiifLogoUrl,
PROJECT_LOGO_URL: DEFAULT_CONFIG.assets.projectLogoUrl,
POSITIVE_LABEL: DEFAULT_CONFIG.labels.positive,
NEGATIVE_LABEL: DEFAULT_CONFIG.labels.negative,
headerDescriptionOpen: false,
projectLogoOk: true,
tab: "iiif",
runs: [],
selectedRun: "",
runBaseUrl: null,
manifestUrl: "",
manifest: null,
canvases: [],
currentIndex: 0,
results: new Map(),
selectionIds: new Set(),
overrides: new Map(),
facet: "all",
editMode: false,
heuristicEnabled: true,
heuristicKeywordsText: "",
heuristicPanelOpen: false,
filteredManifestUrl: null,
filteredManifestObj: null,
mirador: null,
miradorWindowId: null,
miradorCollapsed: false,
pendingCanvasId: null,
miradorRefreshTimer: null,
miradorRefreshQueued: false,
preprocess: null,
inferCfg: null,
sess: null,
inputName: null,
outputName: null,
modelReady: false,
isBusy: false,
isRunning: false,
abortController: null,
progressVisible: false,
progressPct: 0,
progressText: "0 / 0",
classificationConcurrency: 1,
statusMsg: "",
statusType: "",
thresholdPct: 50,
thresholdPctImage: 50,
optimalThresholdPct: 50,
dragOverImage: false,
imageFile: null,
imagePreviewUrl: "",
imageOutput: "",
imageResult: null
};
},
computed: {
/**
* Returns model options for the selector.
*
* @returns {{id: string, label?: string, baseUrl?: string}[]} Model options.
*/
runOptions() {
if (this.appConfig.mode === "online") return this.appConfig.online?.runs || [];
return this.runs.map(run => ({id: run, label: run}));
},
/**
* Returns the human-readable label of the active facet.
*
* @returns {string} Active facet label.
*/
activeFacetLabel() {
const labels = {
all: this.t("allFacet"),
pos: this.t("positiveFacet"),
neg: this.t("negativeFacet"),
overridden: this.t("overriddenFacet"),
pending: this.t("pendingFacet"),
errors: this.t("errorsFacet")
};
return labels[this.facet] || this.facet;
},
/**
* Returns the IIIF threshold as a decimal.
*
* @returns {number} Threshold between 0 and 1.
*/
threshold() {
return this.thresholdPct / 100;
},
/**
* Returns the local image threshold as a decimal.
*
* @returns {number} Threshold between 0 and 1.
*/
thresholdImage() {
return this.thresholdPctImage / 100;
},
/**
* Returns the label used for positive-class scores.
*
* @returns {string} Score label.
*/
scoreLabel() {
return this.t("scoreLabel", `p(${this.POSITIVE_LABEL})`);
},
/**
* Returns a short positive label for manual correction checkboxes.
*
* @returns {string} Short positive label.
*/
positiveLabelShort() {
return this.t("positiveShort", this.POSITIVE_LABEL);
},
/**
* Returns canvases used in the histogram.
*
* @returns {object[]} Histogram items.
*/
histItems() {
if (this.selection.length) return this.selection;
const done = this.canvases.filter(canvas => {
const result = this.results.get(canvas.id);
return result && result.status === "done" && !result.error;
});
if (this.facet === "pos") return done.filter(canvas => this.finalChecked(canvas.id));
if (this.facet === "neg") return done.filter(canvas => !this.finalChecked(canvas.id));
return done;
},
/**
* Returns the current title shown above Mirador.
*
* @returns {string} Current title.
*/
currentTitle() {
const canvas = this.canvases[this.currentIndex];
if (!canvas) return "—";
const result = this.results.get(canvas.id);
const score = result?.status === "done" && !result.error
? ` — ${this.scoreLabel}=${Math.round((result.p_positive ?? 0) * 100)}%`
: "";
const override = this.overrides.has(canvas.id) ? ` (${this.t("override")})` : "";
return `${canvas.label}${score}${override}`;
},
/**
* Returns selected canvases sorted by original manifest order.
*
* @returns {object[]} Selected canvases.
*/
selection() {
const ids = this.selectionIds;
return this.canvases
.filter(canvas => ids.has(canvas.id))
.sort((a, b) => (a._index ?? 0) - (b._index ?? 0));
},
/**
* Returns the current canvas position within the selection.
*
* @returns {number} Current position.
*/
currentSelectedPos() {
if (!this.selection.length) return 0;
const currentId = this.canvases[this.currentIndex]?.id;
const index = this.selection.findIndex(canvas => canvas.id === currentId);
return index >= 0 ? index : 0;
},
/**
* Returns CSS style for the progress bar.
*
* @returns {object} Style object.
*/
progressStyle() {
return {width: `${this.progressPct}%`};
},
/**
* Returns project initials for the logo fallback.
*
* @returns {string} Initials.
*/
projectInitials() {
const name = this.appConfig.project?.name || "Project";
return name
.split(/\s+/)
.filter(Boolean)
.map(part => part[0])
.join("")
.slice(0, 5)
.toUpperCase();
}
},
watch: {
/**
* Re-focuses Mirador when returning to the IIIF tab.
*
* @param {string} newTab - Newly selected tab.
*/
tab(newTab) {
if (newTab === "iiif" && this.manifest && this.mirador) {
const currentCanvasId = this.canvases[this.currentIndex]?.id;
if (currentCanvasId) this.goMiradorToCanvas(currentCanvasId);
}
}
},
methods: {
/**
* Returns a configured UI text.
*
* @param {string} key - Text key.
* @param {string} [fallback] - Fallback text.
* @returns {string} Text.
*/
t(key, fallback = "") {
return this.appConfig.ui?.texts?.[key] || fallback || key;
},
/**
* Updates the status box.
*
* @param {string} message - Message.
* @param {string} [type] - Type: loading, success, error, or empty.
*/
setStatus(message, type = "") {
this.statusMsg = message;
this.statusType = type;
},
/**
* Returns heuristic keywords from the textarea.
*
* @returns {string[]} Keywords.
*/
getHeuristicKeywords() {
return String(this.heuristicKeywordsText || "")
.split(/\n+/)
.map(value => value.trim())
.filter(Boolean);
},
/**
* Checks whether a canvas label matches a forced-negative keyword.
*
* @param {string} label - Canvas label.
* @returns {boolean} True if forced negative.
*/
labelMatchesForceNegative(label) {
if (!this.heuristicEnabled) return false;
const normalizedLabel = normalizeText(label);
const keywords = this.getHeuristicKeywords().map(normalizeText);
return keywords.some(keyword => keyword && normalizedLabel.includes(keyword));
},
/**
* Hides a broken thumbnail image.
*
* @param {Event} event - Image error event.
*/
hideImg(event) {
event.target.style.display = "none";
},
/**
* Returns whether a canvas has a completed usable result.
*
* @param {string} canvasId - Canvas ID.
* @returns {boolean} True if usable.
*/
hasUsableResult(canvasId) {
const result = this.results.get(canvasId);
return !!result && !result.error && result.status === "done";
},
/**
* Returns final positive/negative decision for a canvas.
*
* Manual overrides take precedence over model scores.
*
* @param {string} canvasId - Canvas ID.
* @returns {boolean} True if positive.
*/
finalChecked(canvasId) {
const override = this.overrides.get(canvasId);
if (override && typeof override.checked === "boolean") return override.checked;
const result = this.results.get(canvasId);
if (!result || result.error || result.status !== "done") return false;
return (result.p_positive ?? 0) >= this.threshold;
},
/**
* Sets a manual override.
*
* @param {string} canvasId - Canvas ID.
* @param {boolean} checked - Positive state.
*/
setOverride(canvasId, checked) {
this.overrides.set(canvasId, {checked: Boolean(checked)});
this.applySelection(true, "override");
},
/**
* Clears a manual override.
*
* @param {string} canvasId - Canvas ID.
*/
clearOverride(canvasId) {
this.overrides.delete(canvasId);
this.applySelection(true, "override");
},
/**
* Returns true when the current facet depends on results.
*
* @returns {boolean} Whether facet depends on results.
*/
facetDependsOnResults() {
return ["pos", "neg", "pending", "errors", "overridden"].includes(this.facet);
},
/**
* Schedules a throttled Mirador refresh.
*
* @param {string|null} [targetCanvasId] - Canvas to open after refresh.
*/
queueMiradorRefresh(targetCanvasId = null) {
if (!this.manifest || !this.canvases.length) return;
if (this.miradorRefreshQueued) return;
this.miradorRefreshQueued = true;
clearTimeout(this.miradorRefreshTimer);
this.miradorRefreshTimer = setTimeout(() => {
this.miradorRefreshQueued = false;
this.setFilteredManifestForSelection();
const keepId = targetCanvasId || this.canvases[this.currentIndex]?.id;
const targetId = keepId && this.selectionIds.has(keepId)
? keepId
: (this.selection[0]?.id || null);
this.initMirador(this.filteredManifestUrl);
if (targetId) setTimeout(() => this.goMiradorToCanvas(targetId), 80);
}, 400);
},
/**
* Recomputes the selected canvas IDs from the facet and threshold.
*
* @param {boolean} [keepCurrentIfPossible] - Preserve current canvas if possible.
* @param {string} [reason] - Update reason.
*/
applySelection(keepCurrentIfPossible = false, reason = "ui") {
const listWrap = this.$refs?.listWrap;
const previousScrollTop = listWrap ? listWrap.scrollTop : null;
const ids = new Set();
for (const canvas of this.canvases) {
const result = this.results.get(canvas.id);
const done = result && result.status === "done" && !result.error;
const pending = !result || result.status === "processing";
const errored = result && (result.error || result.status === "error");
const isPositive = done ? this.finalChecked(canvas.id) : false;
const isNegative = done ? !this.finalChecked(canvas.id) : false;
const overridden = this.overrides.has(canvas.id);
let include = true;
switch (this.facet) {
case "pos":
include = done && isPositive;
break;
case "neg":
include = done && isNegative;
break;
case "overridden":
include = overridden;
break;
case "pending":
include = pending;
break;
case "errors":
include = Boolean(errored);
break;
default:
include = true;
}
if (include) ids.add(canvas.id);
}
this.selectionIds = ids;
this.$nextTick(() => {
const nextListWrap = this.$refs?.listWrap;
if (nextListWrap && previousScrollTop !== null) {
nextListWrap.scrollTop = previousScrollTop;
}
});
if (!ids.size) return;
if (reason === "override") return;
if (reason === "classify") {
if (this.facetDependsOnResults()) {
this.queueMiradorRefresh(this.canvases[this.currentIndex]?.id);
}
return;
}
if (reason === "filter") {
this.setFilteredManifestForSelection();
const keepId = this.canvases[this.currentIndex]?.id;
const targetId = keepId && this.selectionIds.has(keepId)
? keepId
: (this.selection[0]?.id || null);
this.initMirador(this.filteredManifestUrl);
if (targetId) setTimeout(() => this.goMiradorToCanvas(targetId), 80);
return;
}
const currentId = this.canvases[this.currentIndex]?.id;
const currentIsVisible = currentId && ids.has(currentId);
if (!keepCurrentIfPossible || !currentIsVisible) {
const firstIndex = this.canvases.findIndex(canvas => ids.has(canvas.id));
if (firstIndex >= 0) {
this.currentIndex = firstIndex;
this.goMiradorToCanvas(this.canvases[firstIndex].id);
}
}
},
/**
* Resolves the first Mirador window ID.
*
* @returns {string|null} Mirador window ID.
*/
resolveMiradorWindowId() {
try {
const store = this.mirador?.store;
if (!store) return null;
const state = store.getState?.();
const windows = state?.windows;
if (!windows) return null;
return Object.keys(windows)[0] || null;
} catch {
return null;
}
},
/**
* Initializes Mirador with a manifest URL.
*
* @param {string} manifestUrl - Manifest URL.
*/
initMirador(manifestUrl) {
const miradorNode = document.getElementById("mirador");
if (miradorNode) miradorNode.innerHTML = "";
this.mirador = Mirador.viewer({
id: "mirador",
windows: [{
manifestId: manifestUrl,
canvasId: null
}],
workspaceControlPanel: {enabled: false},
window: {
allowClose: false,
allowFullscreen: true,
allowMaximize: false,
allowTopMenuButton: true,
defaultSideBarPanel: "info",
sideBarOpen: false
}
});
this.miradorWindowId = null;
setTimeout(() => {
this.miradorWindowId = this.resolveMiradorWindowId();
if (this.pendingCanvasId) {
const canvasId = this.pendingCanvasId;
this.pendingCanvasId = null;
this.goMiradorToCanvas(canvasId);
}
}, 50);
},
/**
* Navigates Mirador to a canvas.
*
* @param {string} canvasId - Canvas ID.
*/
goMiradorToCanvas(canvasId) {
if (!this.mirador || !canvasId) return;
const store = this.mirador.store;
if (!store) return;
if (!this.miradorWindowId) this.miradorWindowId = this.resolveMiradorWindowId();
if (!this.miradorWindowId) {
this.pendingCanvasId = canvasId;
setTimeout(() => this.goMiradorToCanvas(canvasId), 80);
return;
}
try {
store.dispatch({
type: "mirador/SET_CANVAS",
windowId: this.miradorWindowId,
canvasId,
visibleCanvases: [canvasId],
preserveViewport: true
});
store.dispatch({
type: "mirador/FOCUS_WINDOW",
windowId: this.miradorWindowId
});
} catch {
this.pendingCanvasId = canvasId;
setTimeout(() => this.goMiradorToCanvas(canvasId), 80);
}
},
/**
* Selects a canvas and opens it in Mirador.
*
* @param {object} canvas - Normalized canvas.
*/
selectCanvas(canvas) {
this.currentIndex = canvas._index;
if (this.miradorCollapsed) this.miradorCollapsed = false;
this.goMiradorToCanvas(canvas.id);
},
/**
* Opens the first canvas in the current selection.
*/
openFirstSelected() {
if (!this.selection.length) return;
this.selectCanvas(this.selection[0]);
},
/**
* Moves to the next or previous selected canvas.
*
* @param {number} delta - Direction.
*/
goNextSelected(delta) {
if (!this.selection.length) return;
const currentId = this.canvases[this.currentIndex]?.id;
let position = this.selection.findIndex(canvas => canvas.id === currentId);
if (position < 0) position = 0;
position = (position + delta + this.selection.length) % this.selection.length;
this.selectCanvas(this.selection[position]);
},
/**
* Returns the positive-class score for a canvas.
*
* @param {string} canvasId - Canvas ID.
* @returns {number} Score between 0 and 1.
*/
getPositiveScore(canvasId) {
const result = this.results.get(canvasId);
if (!result || result.error || result.status !== "done") return 0;
return Number(result.p_positive ?? 0);
},
/**
* Returns histogram bar style.
*
* @param {string} canvasId - Canvas ID.
* @returns {object} Style object.
*/
sparkBarStyle(canvasId) {
const probability = this.getPositiveScore(canvasId);
return {height: `${Math.max(8, Math.round(probability * 100))}%`};
},
/**
* Returns probability bar style.
*
* @param {string} canvasId - Canvas ID.
* @returns {object} Style object.
*/
probabilityFillStyle(canvasId) {
return {width: `${Math.round(this.getPositiveScore(canvasId) * 100)}%`};
},
/**
* Returns horizontal probability bar style.
*
* @param {number} probability - Probability.
* @returns {object} Style object.
*/
horizontalProbabilityStyle(probability) {
return {width: `${Math.round(probability * 100)}%`};
},
/**
* Returns histogram tooltip.
*
* @param {object} canvas - Canvas.
* @returns {string} Tooltip.
*/
sparkTitle(canvas) {
return `${canvas.label} — ${Math.round(this.getPositiveScore(canvas.id) * 100)}%`;
},
/**
* Returns histogram bar classes.
*
* @param {object} canvas - Canvas.
* @returns {object} Class object.
*/
sparkBarClass(canvas) {
const isCurrent = canvas._index === this.currentIndex;
const result = this.results.get(canvas.id);
const done = result && result.status === "done" && !result.error;
const ok = done ? this.finalChecked(canvas.id) : false;
return {sel: isCurrent, ok, no: done && !ok};
},
/**
* Refreshes available model runs.
*/
async refreshRuns() {
this.isBusy = true;
this.setStatus(this.t("statusLoadingRuns"), "loading");
try {
if (this.appConfig.mode === "online") {
const runs = this.appConfig.online?.runs || [];
this.runs = runs.map(run => run.id);
this.selectedRun = this.runs[0] || "";
this.setStatus(`${this.t("statusOnlineRuns")}: ${runs.length}`, "success");
return;
}
const abortController = new AbortController();
const runs = await loadRunsIndex(this.artefactsBaseUrl, abortController.signal);
this.runs = runs;
this.selectedRun = runs[0] || "";
this.setStatus(`${this.t("statusLocalRuns")}: ${runs.length}`, "success");
} catch (error) {
console.warn(error);
this.setStatus(`${this.t("statusRunsError")}: ${error.message || error}`, "error");
} finally {
this.isBusy = false;
}
},
/**
* Builds a URL inside the selected run directory.
*
* @param {string} pathInRun - Relative path inside the run.
* @returns {string} Full URL.
*/
runUrl(pathInRun) {
const base = (this.runBaseUrl || "").replace(/\/$/, "");
return `${base}/${pathInRun}`.replace(/%2F/g, "/");
},
/**
* Loads preprocessing config, inference config, and ONNX session.
*/
async loadRun() {
if (!this.selectedRun) return;
if (this.appConfig.mode === "online") {
const entry = (this.appConfig.online?.runs || []).find(run => run.id === this.selectedRun);
if (!entry?.baseUrl) {
this.setStatus(this.t("statusOnlineRunMissing"), "error");
this.isBusy = false;
return;
}
this.runBaseUrl = entry.baseUrl;
} else {
const base = this.artefactsBaseUrl.replace(/\/$/, "");
this.runBaseUrl = `${base}/${encodeURIComponent(this.selectedRun)}`.replace(/%2F/g, "/");
}
this.isBusy = true;
this.modelReady = false;
this.setStatus(this.t("statusLoadingModel"), "loading");
const abortController = new AbortController();
try {
const preprocessUrl = withCacheBuster(this.runUrl("preprocess.json"));
const inferUrl = withCacheBuster(this.runUrl("inference_config.json"));
let preprocess = {};
let inferCfg = {};
try {
preprocess = await fetchJsonOrThrow(preprocessUrl, abortController.signal, {noStore: true});
} catch (error) {
console.warn("Unable to load preprocess.json", preprocessUrl, error);
}
inferCfg = await fetchJsonOrThrow(inferUrl, abortController.signal, {noStore: true});
const imgSize = Number(inferCfg.img_size ?? preprocess.img_size ?? 224);
const cfg = {
img_size: imgSize,
resize_size: Number(inferCfg.resize_size ?? preprocess.resize_size ?? imgSize),
preprocess_mode: inferCfg.preprocess_mode ?? preprocess.preprocess_mode ?? "resize_square",
mean: inferCfg.mean ?? preprocess.mean ?? [0.485, 0.456, 0.406],
std: inferCfg.std ?? preprocess.std ?? [0.229, 0.224, 0.225],
threshold: Number(inferCfg.threshold ?? 0.5),
labels: inferCfg.labels ?? preprocess.labels ?? [this.NEGATIVE_LABEL, this.POSITIVE_LABEL],
positive_label: inferCfg.positive_label ?? this.POSITIVE_LABEL,
negative_label: inferCfg.negative_label ?? this.NEGATIVE_LABEL,
positive_index: Number(inferCfg.positive_index ?? 1),
input_name: inferCfg.input_name ?? null,
output_name: inferCfg.output_name ?? null,
model_name: inferCfg.model_name ?? this.selectedRun
};
console.group(`[loadRun] ${this.appConfig.mode} / ${this.selectedRun}`);
console.log("runBaseUrl =", this.runBaseUrl);
console.log("preprocessUrl =", preprocessUrl);
console.log("inferUrl =", inferUrl);
console.log("preprocess =", preprocess);
console.log("inferCfg =", inferCfg);
console.log("resolved cfg =", cfg);
console.groupEnd();
this.preprocess = cfg;
this.inferCfg = inferCfg;
const thresholdPct = Math.round(Number(cfg.threshold ?? 0.5) * 100);
this.optimalThresholdPct = thresholdPct;
this.thresholdPct = thresholdPct;
this.thresholdPctImage = thresholdPct;
const candidates = [
this.runUrl("onnx/model.onnx"),
this.runUrl("classifier.int8.onnx"),
this.runUrl("classifier.onnx")
];
let modelUrl = null;
for (const candidateUrl of candidates) {
try {
const response = await fetch(candidateUrl, {method: "GET", signal: abortController.signal});
if (response.ok) {
modelUrl = candidateUrl;
break;
}
} catch {
// try next candidate
}
}
if (!modelUrl) throw new Error(this.t("errorModelNotFound"));
this.sess = await ort.InferenceSession.create(modelUrl, {executionProviders: ["wasm"]});
this.inputName = cfg.input_name || this.sess.inputNames[0];
this.outputName = cfg.output_name || this.sess.outputNames[0];
this.results = new Map();
this.overrides = new Map();
this.selectionIds = new Set();
this.imageResult = null;
this.imageOutput = "";
this.modelReady = true;
this.setStatus(
`${this.t("statusModelLoaded")} ✅ run=${this.selectedRun} input=${this.inputName} threshold=${cfg.threshold.toFixed(6)} (${thresholdPct}%)`,
"success"
);
} catch (error) {
console.error(error);
this.setStatus(`${this.t("statusLoadRunError")}: ${error.message || error}`, "error");
this.modelReady = false;
this.sess = null;
this.inputName = null;
this.outputName = null;
} finally {
this.isBusy = false;
}
},
/**
* Clears application state while preserving loaded model configuration.
*/
clearAll() {
this.manifest = null;
this.canvases = [];
this.results = new Map();
this.selectionIds = new Set();
this.overrides = new Map();
this.progressVisible = false;
this.progressPct = 0;
this.progressText = "0 / 0";
this.isRunning = false;
this.abortController = null;
this.currentIndex = 0;
this.mirador = null;
this.miradorWindowId = null;
this.pendingCanvasId = null;
if (this.filteredManifestUrl) URL.revokeObjectURL(this.filteredManifestUrl);
this.filteredManifestUrl = null;
this.filteredManifestObj = null;
const miradorNode = document.getElementById("mirador");
if (miradorNode) miradorNode.innerHTML = "";
this.imageResult = null;
this.imageOutput = "";
this.setStatus(this.t("statusResetOk"), "success");
},
/**
* Loads and parses the IIIF manifest URL.
*/
async loadManifest() {
if (!this.manifestUrl) return;
if (!this.modelReady) {
this.setStatus(this.t("statusLoadModelFirst"), "error");
return;
}
this.isBusy = true;
this.setStatus(this.t("statusLoadingManifest"), "loading");
const abortController = new AbortController();
try {
const manifest = await fetchJsonOrThrow(this.manifestUrl.trim(), abortController.signal);
this.manifest = manifest;
const canvases = extractCanvases(manifest);
canvases.forEach((canvas, index) => {
canvas._index = index;
});
this.canvases = canvases;
this.results = new Map();
this.overrides = new Map();
this.currentIndex = 0;
this.applySelection(false);
this.setFilteredManifestForSelection();
this.initMirador(this.filteredManifestUrl);
if (this.canvases[0]) this.goMiradorToCanvas(this.canvases[0].id);
this.setStatus(`${this.t("statusManifestLoaded")} ✅ (${canvases.length} ${this.t("pages")})`, "success");
} catch (error) {
console.error(error);
this.setStatus(`${this.t("statusManifestError")}: ${error.message || error}`, "error");
this.manifest = null;
this.canvases = [];
this.results = new Map();
this.selectionIds = new Set();
} finally {
this.isBusy = false;
}
},
/**
* Resets both IIIF and local-image thresholds to the model optimal threshold.
*
* The optimal threshold is loaded from inference_config.json when the model
* is loaded. After reset, the current IIIF selection is recomputed.
*/
resetThresholdToOptimal() {
this.thresholdPct = this.optimalThresholdPct;
this.thresholdPctImage = this.optimalThresholdPct;
this.applySelection(true, "filter");
},
/**
* Handles image drag-and-drop.
*
* @param {DragEvent} event - Drop event.
*/
onImageDrop(event) {
this.dragOverImage = false;
if (!this.modelReady || this.isBusy) {
this.setStatus(this.t("statusLoadModelBeforeImage"), "error");
return;
}
const files = event.dataTransfer?.files;
if (!files || !files.length) return;
const file = files[0];
if (!file.type.startsWith("image/")) {
this.setStatus(this.t("statusDroppedFileNotImage"), "error");
return;
}
this.setImageFile(file);
},
/**
* Builds a minimal IIIF Presentation API v3 manifest from selected canvases.
*
* @param {object[]} selectedCanvases - Canvases to include.
* @returns {object} IIIF v3 manifest.
*/
buildFilteredManifestV3(selectedCanvases) {
const source = this.manifest || {};
const sourceLabel = source.label || {none: ["Selection"]};
const sourceSummary = source.summary || undefined;
const provenance = this.getModelProvenance();
const exportDate = new Date().toISOString();
const canvasesV3 = selectedCanvases.map(canvas => {
const body = this.makeImageBody(canvas);
const machineAnnotationPage = this.buildMachineAnnotationPage(canvas, exportDate);
const canvasV3 = {
id: canvas.id,
type: "Canvas",
label: typeof canvas.label === "string"
? {none: [canvas.label]}
: (canvas.label || {none: ["Canvas"]}),
width: canvas.width ?? body.width ?? 1000,
height: canvas.height ?? body.height ?? 1000,
items: [{
id: `${canvas.id}#page`,
type: "AnnotationPage",
items: [{
id: `${canvas.id}#painting`,
type: "Annotation",
motivation: "painting",
target: canvas.id,
body
}]
}]
};
if (machineAnnotationPage) canvasV3.annotations = [machineAnnotationPage];
return canvasV3;
});
return {
"@context": "http://iiif.io/api/presentation/3/context.json",
id: `urn:uuid:${crypto.randomUUID()}`,
type: "Manifest",
label: sourceLabel,
summary: sourceSummary,
metadata: [
{label: {none: ["Enrichment task"]}, value: {none: ["classification"]}},
{label: {none: ["Model"]}, value: {none: [provenance.run || "unknown"]}},
{label: {none: ["Model source"]}, value: {none: [provenance.source]}},
{label: {none: ["Model URL"]}, value: {none: [provenance.url || ""]}},
{label: {none: ["Export date"]}, value: {none: [exportDate]}}
],
items: canvasesV3
};
},
/**
* Builds an IIIF image body for a canvas.
*
* @param {object} canvas - Normalized canvas.
* @returns {object} IIIF Image body.
*/
makeImageBody(canvas) {
if (canvas.serviceUrl) {
return {
id: `${canvas.serviceUrl}/full/full/0/default.jpg`,
type: "Image",
format: "image/jpeg",
width: canvas.imgW ?? canvas.width ?? undefined,
height: canvas.imgH ?? canvas.height ?? undefined,
service: [{
id: canvas.serviceUrl,
type: "ImageService2",
profile: "http://iiif.io/api/image/2/level2.json"
}]
};
}
return {
id: canvas.imageUrl || canvas.thumb,
type: "Image",
format: canvas.format || "image/jpeg",
width: canvas.imgW ?? canvas.width ?? undefined,
height: canvas.imgH ?? canvas.height ?? undefined
};
},
/**
* Updates the blob manifest used by Mirador and export.
*/
setFilteredManifestForSelection() {
if (this.filteredManifestUrl) {
URL.revokeObjectURL(this.filteredManifestUrl);
this.filteredManifestUrl = null;
}
const manifest = this.buildFilteredManifestV3(this.selection);
this.filteredManifestObj = manifest;
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
type: "application/json;charset=utf-8"
});
this.filteredManifestUrl = URL.createObjectURL(blob);
},
/**
* Computes the positive class index from labels and aliases.
*
* @returns {number} Positive class index.
*/
computePositiveIndex() {
const labels = Array.isArray(this.preprocess?.labels)
? this.preprocess.labels
: [this.NEGATIVE_LABEL, this.POSITIVE_LABEL];
const aliases = [
this.preprocess?.positive_label,
this.POSITIVE_LABEL,
...(this.appConfig.labels?.positiveAliases || []),
"1"
].filter(Boolean);
for (const alias of aliases) {
const index = labels.indexOf(alias);
if (index >= 0) return index;
}
return Number(this.preprocess?.positive_index ?? 1);
},
/**
* Computes the negative class index from labels and aliases.
*
* @returns {number} Negative class index.
*/
computeNegativeIndex() {
const labels = Array.isArray(this.preprocess?.labels)
? this.preprocess.labels
: [this.NEGATIVE_LABEL, this.POSITIVE_LABEL];
const aliases = [
this.preprocess?.negative_label,
this.NEGATIVE_LABEL,
...(this.appConfig.labels?.negativeAliases || []),
"0"
].filter(Boolean);
for (const alias of aliases) {
const index = labels.indexOf(alias);
if (index >= 0) return index;
}
const positiveIndex = this.computePositiveIndex();
return labels.length === 2 ? 1 - positiveIndex : 0;
},
/**
* Runs model inference on an HTML image element.
*
* @param {HTMLImageElement} imageElement - Image element.
* @returns {Promise<{logits: number[], probs: number[]}>} Inference outputs.
*/
async inferImageElement(imageElement) {
if (!this.sess) throw new Error("ONNX session is not loaded.");
const input = imageToTensor(imageElement, this.preprocess);
const inputName = this.inputName || this.sess.inputNames[0];
const results = await this.sess.run({[inputName]: input});
const outputName = this.outputName || this.sess.outputNames[0];
const logits = Array.from(results[outputName].data);
const probs = softmax(logits);
return {logits, probs};
},
/**
* Classifies one IIIF canvas.
*
* @param {object} canvas - Normalized canvas.
* @returns {Promise<object>} Classification result.
*/
async classifyCanvasOne(canvas) {
if (this.labelMatchesForceNegative(canvas.label)) {
return {
status: "done",
p_positive: 0.0,
checked: false,
skipped: true,
skipped_reason: "heuristic_force_negative"
};
}
const imageUrl = await bestImageUrlForInference(canvas, 250);
if (!imageUrl) throw new Error(this.t("errorNoCanvasImageUrl"));
const response = await fetch(imageUrl, {signal: this.abortController?.signal});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
try {
const image = new Image();
image.crossOrigin = "anonymous";
image.src = objectUrl;
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
});
const {logits, probs} = await this.inferImageElement(image);
const positiveIndex = this.computePositiveIndex();
const pPositive = Number(probs[positiveIndex] ?? 0);
return {
status: "done",
p_positive: pPositive,
checked: pPositive >= this.threshold,
logits,
probs
};
} finally {
URL.revokeObjectURL(objectUrl);
}
},
/**
* Classifies all canvases with limited concurrency.
*/
async classifyAll() {
if (!this.modelReady || !this.sess) {
this.setStatus(this.t("statusLoadModelFirst"), "error");
return;
}
if (!this.canvases.length) {
this.setStatus(this.t("statusLoadManifestFirst"), "error");
return;
}
const concurrency = Math.max(1, Math.min(20, Number(this.classificationConcurrency) || 1));
this.isRunning = true;
this.isBusy = true;
this.progressVisible = true;
this.progressPct = 0;
this.progressText = `0 / ${this.canvases.length}`;
this.setStatus(`${this.t("statusClassificationRunning")} (${concurrency} ${this.t("parallelSuffix")})`, "loading");
this.abortController = new AbortController();
let done = 0;
const total = this.canvases.length;
const updateProgress = () => {
this.progressPct = Math.round((done / total) * 100);
this.progressText = `${done} / ${total}`;
};
try {
for (let start = 0; start < this.canvases.length; start += concurrency) {
if (this.abortController.signal.aborted) break;
const batch = this.canvases.slice(start, start + concurrency);
for (const canvas of batch) {
this.results.set(canvas.id, {status: "processing"});
}
this.applySelection(true, "classify");
const batchResults = await Promise.allSettled(batch.map(async canvas => {
if (this.abortController.signal.aborted) return {canvas, skippedByAbort: true};
try {
return {canvas, result: await this.classifyCanvasOne(canvas)};
} catch (error) {
return {
canvas,
result: {status: "error", error: error?.message || String(error)}
};
}
}));
for (const item of batchResults) {
if (item.status !== "fulfilled") {
done++;
continue;
}
const {canvas, result, skippedByAbort} = item.value;
if (!skippedByAbort) {
this.results.set(canvas.id, result);
done++;
}
}
updateProgress();
this.applySelection(true, "classify");
}
const positives = [...this.results.entries()]
.filter(([id, result]) => result && result.status === "done" && !result.error && this.finalChecked(id))
.length;
const statusMessage = this.abortController.signal.aborted
? `${this.t("statusStopped")} ✅ (${done}/${total}) — ${this.t("positivesAtThreshold")}: ${positives}`
: `${this.t("statusDone")} ✅ — ${this.t("positivesAtThreshold")}: ${positives}`;
this.setStatus(statusMessage, "success");
} finally {
this.isRunning = false;
this.isBusy = false;
}
},
/**
* Requests cancellation of the current classification run.
*/
stop() {
if (this.abortController) this.abortController.abort();
this.isRunning = false;
this.isBusy = false;
this.setStatus(this.t("statusStopRequested"), "error");
},
/**
* Returns a human-readable processing status.
*
* @param {string} canvasId - Canvas ID.
* @returns {string} Status text.
*/
itemStatusText(canvasId) {
const result = this.results.get(canvasId);
if (!result) return this.t("statusPending");
if (result.error) return this.t("statusError");
if (result.status === "processing") return this.t("statusProcessing");
if (result.skipped) return this.t("heuristicSkipped");
if (result.status === "done") {
return `${this.scoreLabel}=${Math.round((result.p_positive ?? 0) * 100)}%`;
}
return "…";
},
/**
* Downloads the currently filtered IIIF manifest.
*/
exportSelection() {
this.setFilteredManifestForSelection();
const manifest = this.filteredManifestObj;
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
type: "application/json;charset=utf-8"
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `iiif_manifest_filtered_${this.facet}_${this.selectedRun || "run"}.json`;
anchor.click();
URL.revokeObjectURL(url);
this.setStatus(`${this.t("statusExportOk")} ✅ (${manifest.items?.length || 0} canvases)`, "success");
},
/**
* Stores an image file and creates a preview URL.
*
* @param {File} file - Image file.
*/
setImageFile(file) {
this.imageFile = file;
if (this.imagePreviewUrl) URL.revokeObjectURL(this.imagePreviewUrl);
this.imagePreviewUrl = URL.createObjectURL(file);
this.imageOutput = "";
this.imageResult = null;
this.setStatus(`${this.t("statusImageLoaded")}: ${file.name}`, "success");
},
/**
* Handles file picker changes.
*
* @param {Event} event - Change event.
*/
onFile(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
this.setStatus(this.t("statusSelectedFileNotImage"), "error");
return;
}
this.setImageFile(file);
},
/**
* Clears local image state.
*/
clearImage() {
if (this.imagePreviewUrl) URL.revokeObjectURL(this.imagePreviewUrl);
this.imageFile = null;
this.imagePreviewUrl = "";
this.imageOutput = "";
this.imageResult = null;
},
/**
* Removes query string and hash from a URL.
*
* @param {string|null} url - Raw URL.
* @returns {string|null} Clean URL.
*/
cleanUrl(url) {
if (!url) return null;
try {
const parsed = new URL(url, window.location.href);
parsed.search = "";
parsed.hash = "";
return parsed.toString().replace(/\/$/, "");
} catch {
return String(url).split("?")[0].split("#")[0].replace(/\/$/, "");
}
},
/**
* Converts a Hugging Face file URL to a stable repository URL when possible.
*
* @param {string|null} url - Hugging Face or generic URL.
* @returns {string|null} Stable URL.
*/
toStableHuggingFaceRepoUrl(url) {
if (!url) return null;
const clean = this.cleanUrl(url);
if (!clean) return null;
try {
const parsed = new URL(clean);
if (parsed.hostname !== "huggingface.co") return clean;
const parts = parsed.pathname.split("/").filter(Boolean);
if (parts[0] === "datasets" && parts.length >= 3) {
return `${parsed.origin}/datasets/${parts[1]}/${parts[2]}`;
}
if (parts[0] === "spaces" && parts.length >= 3) {
return `${parsed.origin}/spaces/${parts[1]}/${parts[2]}`;
}
if (parts.length >= 2) return `${parsed.origin}/${parts[0]}/${parts[1]}`;
return clean;
} catch {
return clean;
}
},
/**
* Returns current model provenance.
*
* @returns {{source: string, url: string|null, run: string|null}} Provenance.
*/
getModelProvenance() {
if (this.appConfig.mode === "online") {
const entry = (this.appConfig.online?.runs || []).find(run => run.id === this.selectedRun);
const stableRepoUrl =
this.HF_REPO_URL ||
this.appConfig.online?.huggingFaceRepoURL ||
entry?.repoUrl ||
entry?.repositoryUrl ||
entry?.modelUrl ||
this.toStableHuggingFaceRepoUrl(entry?.baseUrl || this.runBaseUrl);
return {
source: "huggingface",
url: this.toStableHuggingFaceRepoUrl(stableRepoUrl),
run: this.selectedRun || null
};
}
return {
source: "local",
url: this.cleanUrl(this.runBaseUrl),
run: this.selectedRun || null
};
},
/**
* Builds a reusable machine-generated annotation payload.
*
* @param {object} params - Payload parameters.
* @returns {object} Payload.
*/
buildMachinePayload({task, type, data, date}) {
const provenance = this.getModelProvenance();
return {
type,
task,
model: provenance.run,
model_source: provenance.source,
model_url: provenance.url,
date,
data
};
},
/**
* Builds one IIIF machine annotation.
*
* @param {object} canvas - Canvas.
* @param {object} params - Annotation parameters.
* @returns {object} IIIF Annotation.
*/
buildMachineAnnotation(canvas, {task, motivation, payload}) {
return {
id: `${canvas.id}#machine-annotation-${task}`,
type: "Annotation",
motivation,
target: canvas.id,
body: {
type: "TextualBody",
format: "application/json",
value: JSON.stringify(payload)
}
};
},
/**
* Builds a machine annotation page for a canvas.
*
* @param {object} canvas - Canvas.
* @param {string} exportDate - ISO export date.
* @returns {object|null} AnnotationPage or null.
*/
buildMachineAnnotationPage(canvas, exportDate) {
const items = [];
const result = this.results.get(canvas.id);
if (result && result.status === "done" && !result.error) {
const finalIsPositive = this.finalChecked(canvas.id);
const prediction = finalIsPositive ? this.POSITIVE_LABEL : this.NEGATIVE_LABEL;
const score = Number(result.p_positive ?? 0);
const classificationPayload = this.buildMachinePayload({
task: "classification",
type: "ImageClassificationResult",
date: exportDate,
data: {
prediction,
score,
score_label: this.POSITIVE_LABEL,
threshold: Number(this.threshold),
skipped: Boolean(result.skipped),
skipped_reason: result.skipped_reason || null
}
});
items.push(this.buildMachineAnnotation(canvas, {
task: "classification",
motivation: "classifying",
payload: classificationPayload
}));
}
if (!items.length) return null;
return {
id: `${canvas.id}#machine-annotations`,
type: "AnnotationPage",
label: {none: ["Machine-generated annotations"]},
items
};
},
/**
* Runs prediction on the selected local image.
*/
async predictImage() {
if (!this.imageFile || !this.sess) return;
this.isBusy = true;
this.setStatus(this.t("statusImageInference"), "loading");
try {
const objectUrl = URL.createObjectURL(this.imageFile);
const image = new Image();
image.crossOrigin = "anonymous";
image.src = objectUrl;
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
});
const {logits, probs} = await this.inferImageElement(image);
URL.revokeObjectURL(objectUrl);
const positiveIndex = this.computePositiveIndex();
const pPositive = Number(probs[positiveIndex] ?? 0);
const threshold = this.thresholdImage;
const pred = pPositive >= threshold ? this.POSITIVE_LABEL : this.NEGATIVE_LABEL;
const labels = Array.isArray(this.preprocess?.labels)
? this.preprocess.labels
: probs.map((_, index) => `${this.t("classLabelPrefix")}_${index}`);
this.imageResult = {
pred,
p_positive: pPositive,
probs,
logits,
labels
};
this.imageOutput = JSON.stringify({
run: this.selectedRun,
pred,
p_positive: pPositive,
threshold,
probs,
logits,
labels,
preprocess: {
img_size: this.preprocess.img_size,
resize_size: this.preprocess.resize_size,
preprocess_mode: this.preprocess.preprocess_mode,
mean: this.preprocess.mean,
std: this.preprocess.std
}
}, null, 2);
this.setStatus(
`${this.t("statusImageDone")} ✅ — ${pred} (${this.scoreLabel}=${(pPositive * 100).toFixed(1)}%)`,
"success"
);
} catch (error) {
console.error(error);
this.setStatus(`${this.t("statusPredictionError")}: ${error.message || error}`, "error");
} finally {
this.isBusy = false;
}
}
},
/**
* Initializes configuration and model list.
*/
async mounted() {
this.appConfig = await loadAppConfig();
document.title = this.appConfig.ui?.title || DEFAULT_CONFIG.ui.title;
this.headerDescriptionOpen = !(this.appConfig.ui?.descriptionCollapsedByDefault ?? true);
this.IIIF_LOGO_URL = this.appConfig.assets?.iiifLogoUrl || DEFAULT_CONFIG.assets.iiifLogoUrl;
this.PROJECT_LOGO_URL =
this.appConfig.project?.logoUrl ||
this.appConfig.assets?.projectLogoUrl ||
DEFAULT_CONFIG.assets.projectLogoUrl;
this.POSITIVE_LABEL = this.appConfig.labels?.positive || DEFAULT_CONFIG.labels.positive;
this.NEGATIVE_LABEL = this.appConfig.labels?.negative || DEFAULT_CONFIG.labels.negative;
this.manifestUrl = this.appConfig.ui?.defaultManifestUrl || "";
this.heuristicEnabled = this.appConfig.heuristic?.enabled ?? true;
this.heuristicPanelOpen = !(this.appConfig.heuristic?.collapsedByDefault ?? true);
this.heuristicKeywordsText = (this.appConfig.heuristic?.keywordsForceNegative || []).join("\n");
if (this.appConfig.mode === "online") {
this.appConfig.ui.showHelp = false;
this.appConfig.ui.showArtefactsInput = false;
this.HF_REPO_LABEL = this.appConfig.online?.huggingFaceRepoLabel || "";
this.HF_REPO_URL = this.appConfig.online?.huggingFaceRepoURL || "";
} else {
this.appConfig.ui.showHelp = this.appConfig.ui.showHelp ?? true;
this.appConfig.ui.showArtefactsInput = this.appConfig.ui.showArtefactsInput ?? true;
this.artefactsBaseUrl = this.appConfig.local?.defaultArtefactsBaseUrl || DEFAULT_CONFIG.local.defaultArtefactsBaseUrl;
this.HF_REPO_LABEL = "";
this.HF_REPO_URL = "";
}
this.setStatus(this.t("statusReady"), "");
await this.refreshRuns();
}
}).mount("#app");
</script>
</body>
</html>