From 3944605d33e5870a76fd56d5fa083f4931ba95ff Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Apr 2026 18:39:47 -0400 Subject: [PATCH] feat(bulk): consensus ML suggestions + frontend polish Bulk editor now loads consensus ML suggestions across the current selection via a new POST /api/suggestions/bulk endpoint, powered by get_bulk_suggestions() in tag_suggestions.py. A tag is surfaced only if it was suggested for or already applied to >= 80% of the selection; coverage counts include images that already have the tag so a near- universal tag isn't penalized for dropping out of suggestion lists. Accept-only chips (no reject) match the rest of the suggestions UX. Frontend polish in the same commit: - Showcase session dedup (exclude seen ids, reset on second lap) and aspect-ratio-aware placeholder heights for better column balancing - Modal touch-swipe navigation on the image wrapper, auto-focus of the tag input on desktop (>=768px), and autocomplete flip-up when the dropdown would cover the Suggestions section - Settings template inline styles replaced with utility classes - Inline series-editor script extracted to gallery-series-editor.js Co-Authored-By: Claude Opus 4.7 --- app/main.py | 30 +++ app/services/tag_suggestions.py | 109 ++++++++- app/static/js/bulk-select.js | 131 +++++++++++ app/static/js/gallery-series-editor.js | 83 +++++++ app/static/js/showcase.js | 41 +++- app/static/js/view-modal.js | 64 +++++- app/templates/gallery.html | 91 +------- app/templates/settings.html | 300 +++++++++++++++++++------ app/templates/showcase.html | 2 + 9 files changed, 693 insertions(+), 158 deletions(-) create mode 100644 app/static/js/gallery-series-editor.js diff --git a/app/main.py b/app/main.py index 399a8ad..9c82f34 100644 --- a/app/main.py +++ b/app/main.py @@ -95,6 +95,8 @@ def random_images_api(): 'thumb_url': url_for('main.serve_image', filename=thumb_path), 'full_url': url_for('main.serve_image', filename=full_path), 'is_video': is_video, + 'width': img.width, + 'height': img.height, 'date': (img.taken_at or img.imported_at).strftime('%Y-%m-%d') if (img.taken_at or img.imported_at) else '', 'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags] }) @@ -714,6 +716,34 @@ def get_image_suggestions(image_id): return jsonify({'ok': True, 'suggestions': suggestions}) +@main.post("/api/suggestions/bulk") +def get_bulk_suggestions_api(): + """Consensus ML suggestions across a selection of images. + + Body: {"image_ids": [...], "threshold": 0.8} + Returns grouped suggestions where each tag was suggested for or already + applied to at least `threshold` fraction of the selected images. + """ + from app.services.tag_suggestions import get_bulk_suggestions + payload = request.get_json(force=True, silent=True) or {} + image_ids = payload.get('image_ids') or [] + try: + image_ids = [int(x) for x in image_ids] + except (TypeError, ValueError): + return jsonify({'ok': False, 'error': 'invalid_image_ids'}), 400 + if not image_ids: + return jsonify({'ok': False, 'error': 'no_image_ids'}), 400 + + try: + threshold = float(payload.get('threshold', 0.8)) + except (TypeError, ValueError): + threshold = 0.8 + threshold = max(0.0, min(1.0, threshold)) + + suggestions = get_bulk_suggestions(image_ids, threshold=threshold) + return jsonify({'ok': True, 'suggestions': suggestions, 'evaluated': len(image_ids), 'threshold': threshold}) + + @main.post("/image//suggestions/accept") def accept_image_suggestion(image_id): from app.models import SuggestionFeedback diff --git a/app/services/tag_suggestions.py b/app/services/tag_suggestions.py index 84d7fdb..1cea1a8 100644 --- a/app/services/tag_suggestions.py +++ b/app/services/tag_suggestions.py @@ -197,7 +197,13 @@ def _merge(wd14: list[dict], embedding: list[dict]) -> list[dict]: return list(by_name.values()) -def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict: +def get_suggestions( + image_id: int, + top_k_per_category: int = 10, + *, + cfg: dict | None = None, + existing_all: set[str] | None = None, +) -> dict: """Return suggestions grouped by category for one image. Shape: @@ -207,11 +213,15 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict: 'general': [...], } Ordered by confidence desc within each category. 'meta' and 'rating' are omitted. + + The optional `cfg` and `existing_all` kwargs let callers that loop over many + images amortize the config fetch and the Tag-table scan. """ if ImageRecord.query.get(image_id) is None: return {'character': [], 'copyright': [], 'general': []} - cfg = _config() + if cfg is None: + cfg = _config() already = _existing_tag_names_for_image(image_id) merged = _merge( @@ -219,7 +229,8 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict: _embedding_tag_suggestions(image_id, cfg, already), ) - existing_all = _existing_tag_names() + if existing_all is None: + existing_all = _existing_tag_names() grouped: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []} for s in merged: @@ -233,3 +244,95 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict: grouped[cat] = grouped[cat][:top_k_per_category] return grouped + + +def get_bulk_suggestions( + image_ids: list[int], + threshold: float = 0.8, + top_k_per_category: int = 10, +) -> dict: + """Return consensus suggestions across multiple selected images. + + A tag is included only if it was suggested for (or already applied to) + at least `threshold` fraction of the selected images. Confidence returned + is the mean over the images where it was actually suggested; images that + already have the tag contribute to coverage but not to confidence. + + Shape matches get_suggestions(): {'character': [...], 'copyright': [...], 'general': [...]}. + Each item also carries 'coverage' (float in [0, 1]) and 'covered_count' (int) + so the UI can display how broad the consensus is. + """ + if not image_ids: + return {'character': [], 'copyright': [], 'general': []} + + cfg = _config() + existing_all = _existing_tag_names() + total = len(image_ids) + + # Batched lookup: which selected images already have which tags. Used so a + # tag that's already on most of the selection isn't penalized just because + # it drops out of those images' suggestion lists. + existing_by_image: dict[int, set[str]] = {iid: set() for iid in image_ids} + rows = ( + db.session.query(image_tags.c.image_id, Tag.name) + .join(Tag, Tag.id == image_tags.c.tag_id) + .filter(image_tags.c.image_id.in_(image_ids)) + .all() + ) + for iid, name in rows: + existing_by_image.setdefault(iid, set()).add(name) + + # Aggregate suggested-count and confidence per tag name. + # suggested_count and existing_count are disjoint because get_suggestions() + # excludes tags the image already has. + tag_stats: dict[str, dict] = {} + for image_id in image_ids: + grouped = get_suggestions( + image_id, + top_k_per_category=top_k_per_category, + cfg=cfg, + existing_all=existing_all, + ) + for category, items in grouped.items(): + for item in items: + name = item['name'] + stat = tag_stats.get(name) + if stat is None: + stat = { + 'name': name, + 'category': category, + 'suggested_count': 0, + 'sum_conf': 0.0, + 'source': item['source'], + } + tag_stats[name] = stat + stat['suggested_count'] += 1 + stat['sum_conf'] += item['confidence'] + + # Only tags that appeared somewhere as suggestions are candidates. Tags + # already universally applied don't appear here — correctly, since there's + # nothing for Accept to do. + result: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []} + for stat in tag_stats.values(): + existing_count = sum( + 1 for iid in image_ids if stat['name'] in existing_by_image.get(iid, set()) + ) + covered_count = stat['suggested_count'] + existing_count + coverage = covered_count / total + if coverage < threshold: + continue + mean_conf = stat['sum_conf'] / stat['suggested_count'] + result[stat['category']].append({ + 'name': stat['name'], + 'category': stat['category'], + 'confidence': mean_conf, + 'coverage': coverage, + 'covered_count': covered_count, + 'source': stat['source'], + 'exists_in_db': stat['name'] in existing_all, + }) + + for cat in result: + result[cat].sort(key=lambda x: x['confidence'], reverse=True) + result[cat] = result[cat][:top_k_per_category] + return result diff --git a/app/static/js/bulk-select.js b/app/static/js/bulk-select.js index bb45fd4..17168e4 100644 --- a/app/static/js/bulk-select.js +++ b/app/static/js/bulk-select.js @@ -32,6 +32,8 @@ dom.tagInput = document.getElementById('bulkTagInput'); dom.autocomplete = document.getElementById('bulkTagAutocomplete'); dom.commonTags = document.getElementById('commonTagsList'); + dom.bulkSuggestionsList = document.getElementById('bulkSuggestionsList'); + dom.bulkSuggestionsHint = document.getElementById('bulkSuggestionsHint'); if (!dom.selectBtn || !dom.panel) return; @@ -124,6 +126,7 @@ updateAllOrderBadges(); updateCount(); loadCommonTags(); + loadBulkSuggestions(); dispatchSelectionChanged(); } @@ -137,6 +140,7 @@ }); updateCount(); updateCommonTagsDisplay(); + clearBulkSuggestions(); dispatchSelectionChanged(); } @@ -288,6 +292,133 @@ }); } + // --------------------------- + // Bulk ML suggestions (consensus across selection) + // --------------------------- + const BULK_SUGGESTIONS_MIN = 2; + const BULK_SUGGESTIONS_THRESHOLD = 0.8; + const BULK_SUGGESTIONS_CATEGORY_ORDER = ['character', 'copyright', 'general']; + const BULK_SUGGESTIONS_CATEGORY_LABEL = { + character: 'Characters', + copyright: 'Fandoms', + general: 'General', + }; + + function clearBulkSuggestions() { + if (dom.bulkSuggestionsList) dom.bulkSuggestionsList.innerHTML = ''; + setBulkSuggestionsHint('Select 2+ images to see consensus suggestions.'); + } + + function setBulkSuggestionsHint(text, visible = true) { + if (!dom.bulkSuggestionsHint) return; + dom.bulkSuggestionsHint.textContent = text; + dom.bulkSuggestionsHint.style.display = visible ? '' : 'none'; + } + + async function loadBulkSuggestions() { + if (!dom.bulkSuggestionsList) return; + const ids = Array.from(state.selectedIds); + if (ids.length < BULK_SUGGESTIONS_MIN) { + clearBulkSuggestions(); + return; + } + + setBulkSuggestionsHint('Loading…'); + dom.bulkSuggestionsList.innerHTML = ''; + + try { + const r = await fetch('/api/suggestions/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image_ids: ids, threshold: BULK_SUGGESTIONS_THRESHOLD }), + }); + const j = await r.json(); + if (!j.ok) { + setBulkSuggestionsHint('Could not load suggestions.'); + return; + } + renderBulkSuggestions(j.suggestions || {}, ids.length); + } catch (e) { + setBulkSuggestionsHint('Could not load suggestions.'); + } + } + + function renderBulkSuggestions(grouped, selectionSize) { + if (!dom.bulkSuggestionsList) return; + dom.bulkSuggestionsList.innerHTML = ''; + + let total = 0; + for (const cat of BULK_SUGGESTIONS_CATEGORY_ORDER) { + const items = (grouped && grouped[cat]) || []; + if (items.length === 0) continue; + total += items.length; + + const group = document.createElement('div'); + group.className = 'suggestions-category'; + group.dataset.category = cat; + + const label = document.createElement('div'); + label.className = 'suggestions-category-label'; + label.textContent = BULK_SUGGESTIONS_CATEGORY_LABEL[cat] || cat; + group.appendChild(label); + + const chips = document.createElement('div'); + chips.className = 'suggestions-chips'; + for (const item of items) { + chips.appendChild(buildBulkSuggestionChip(item, selectionSize)); + } + group.appendChild(chips); + dom.bulkSuggestionsList.appendChild(group); + } + + if (total === 0) { + setBulkSuggestionsHint('No consensus suggestions for this selection.'); + } else { + setBulkSuggestionsHint('', false); + } + } + + function buildBulkSuggestionChip(item, selectionSize) { + const chip = document.createElement('div'); + chip.className = 'suggestion-chip suggestion-chip-bulk'; + chip.dataset.tagName = item.name; + chip.dataset.category = item.category; + const pct = Math.round((item.confidence || 0) * 100); + const covered = item.covered_count != null ? item.covered_count : selectionSize; + chip.title = `${item.name} — suggested for ${covered}/${selectionSize}`; + + chip.innerHTML = ` + + + ${escapeHtml(item.name)} + ${pct}% · ${covered}/${selectionSize} + + `; + + const acceptBtn = chip.querySelector('.sugg-accept'); + if (acceptBtn) { + acceptBtn.addEventListener('click', () => acceptBulkSuggestion(chip)); + } + return chip; + } + + async function acceptBulkSuggestion(chip) { + if (chip.dataset.disabled === 'true') return; + chip.dataset.disabled = 'true'; + chip.style.pointerEvents = 'none'; + chip.style.opacity = '0.5'; + const name = chip.dataset.tagName; + try { + await bulkAddTag(name); + chip.classList.add('leaving'); + setTimeout(() => chip.remove(), 200); + } catch { + chip.dataset.disabled = 'false'; + chip.style.pointerEvents = ''; + chip.style.opacity = ''; + } + } + async function handleBulkAddTag(e) { e.preventDefault(); const name = dom.tagInput.value.trim(); diff --git a/app/static/js/gallery-series-editor.js b/app/static/js/gallery-series-editor.js new file mode 100644 index 0000000..2d2d9fe --- /dev/null +++ b/app/static/js/gallery-series-editor.js @@ -0,0 +1,83 @@ +// /app/static/js/gallery-series-editor.js +// Series editor wired into the bulk-editor panel on series-filtered gallery views. +// No-ops when the page has no series context. + +document.addEventListener('DOMContentLoaded', () => { + const seriesInfo = window.galleryState && window.galleryState.seriesInfo; + if (!seriesInfo) return; + + const startPageInput = document.getElementById('startPageInput'); + const bulkAddToSeriesBtn = document.getElementById('bulkAddToSeriesBtn'); + const feedbackEl = document.getElementById('seriesEditorFeedback'); + if (!startPageInput || !bulkAddToSeriesBtn || !feedbackEl) return; + + const seriesTagId = seriesInfo.tag_id; + + function updateAddButtonState() { + const selectedCount = window.selectedImages ? window.selectedImages.size : 0; + bulkAddToSeriesBtn.disabled = selectedCount === 0; + bulkAddToSeriesBtn.textContent = selectedCount > 0 + ? `Add ${selectedCount} to Series` + : 'Add to Series'; + } + + document.addEventListener('selectionChanged', updateAddButtonState); + + bulkAddToSeriesBtn.addEventListener('click', async () => { + if (!window.selectedImages || window.selectedImages.size === 0) return; + + const imageIds = window.selectionOrder ? [...window.selectionOrder] : Array.from(window.selectedImages); + const startPage = parseInt(startPageInput.value) || 1; + + bulkAddToSeriesBtn.disabled = true; + bulkAddToSeriesBtn.textContent = 'Adding...'; + feedbackEl.innerHTML = ''; + + try { + const r = await fetch(`/api/series/${seriesTagId}/pages/bulk-add`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image_ids: imageIds, start_page: startPage }) + }); + const data = await r.json(); + + if (data.ok) { + let msg = `Added ${data.added.length} image(s) to series.`; + if (data.skipped.length > 0) { + msg += ` Skipped ${data.skipped.length}.`; + } + feedbackEl.innerHTML = ``; + + if (data.added.length > 0) { + const lastPage = data.added[data.added.length - 1].page_number; + startPageInput.value = lastPage + 1; + } + + if (window.clearSelection) { + window.clearSelection(); + } + + const pageCountEl = document.getElementById('seriesPageCount'); + if (pageCountEl) { + const currentCount = parseInt(pageCountEl.textContent) || 0; + pageCountEl.textContent = `${currentCount + data.added.length} pages`; + } + + // "Read Series" button only renders when page_count > 0. If this was the first + // batch of pages added, reload so the button appears. + const readBtn = document.querySelector('.series-header-actions .primary-btn'); + if (!readBtn && data.added.length > 0) { + location.reload(); + } + } else { + feedbackEl.innerHTML = ``; + } + } catch (e) { + feedbackEl.innerHTML = ``; + } finally { + updateAddButtonState(); + } + }); + + updateAddButtonState(); +}); diff --git a/app/static/js/showcase.js b/app/static/js/showcase.js index a9e69ee..e786a10 100644 --- a/app/static/js/showcase.js +++ b/app/static/js/showcase.js @@ -20,6 +20,15 @@ let columnHeights = []; let isLoading = false; let currentColumnCount = 0; + const seenImageIds = new Set(); + + function buildRandomUrl(count) { + const params = new URLSearchParams({ count: String(count) }); + if (seenImageIds.size > 0) { + params.set('exclude', [...seenImageIds].join(',')); + } + return `/api/random-images?${params.toString()}`; + } // Initialize function init() { @@ -149,6 +158,13 @@ const imgEl = document.createElement('img'); imgEl.src = img.thumb_url; imgEl.alt = img.filename; + // Reserve the correct layout slot before the image loads so the masonry + // doesn't reflow when thumbnails arrive. Width/height populate on import + // and may be missing on older records — fall back to natural sizing. + if (img.width && img.height) { + imgEl.width = img.width; + imgEl.height = img.height; + } if (useLazyLoading) { imgEl.loading = 'lazy'; } @@ -173,7 +189,10 @@ } function distributeImages(images, useLazyLoading = false, animate = false) { + const colWidth = columns[0] ? columns[0].clientWidth : 0; + images.forEach((img, i) => { + seenImageIds.add(img.id); const colIndex = getShortestColumnIndex(); const item = createImageElement(img, useLazyLoading); @@ -186,7 +205,13 @@ } columns[colIndex].appendChild(item); - columnHeights[colIndex] += 300; + // Predict rendered height from the intrinsic aspect ratio so masonry + // packing is accurate before the thumb loads. Fall back to a flat guess + // when W/H are missing. + const predictedHeight = (colWidth && img.width && img.height) + ? colWidth * (img.height / img.width) + : 300; + columnHeights[colIndex] += predictedHeight; const imgEl = item.querySelector('img'); imgEl.onload = () => { updateColumnHeights(); }; }); @@ -228,10 +253,18 @@ container.classList.add('loading'); try { - const response = await fetch(`/api/random-images?count=20`); + let response = await fetch(buildRandomUrl(20)); if (!response.ok) throw new Error('Failed to fetch'); + let data = await response.json(); - const data = await response.json(); + // If the collection is exhausted for this session, reset and retry once + // so shuffle keeps feeling fresh on a "second lap". + if (data.images.length === 0 && seenImageIds.size > 0) { + seenImageIds.clear(); + response = await fetch(buildRandomUrl(20)); + if (!response.ok) throw new Error('Failed to fetch'); + data = await response.json(); + } // Clear everything columns.forEach(col => col.innerHTML = ''); @@ -254,7 +287,7 @@ isLoading = true; try { - const response = await fetch(`/api/random-images?count=${LOAD_BATCH_SIZE}`); + const response = await fetch(buildRandomUrl(LOAD_BATCH_SIZE)); if (!response.ok) throw new Error('Failed to fetch'); const data = await response.json(); diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js index ba333ed..e859b1d 100644 --- a/app/static/js/view-modal.js +++ b/app/static/js/view-modal.js @@ -630,9 +630,30 @@ document.addEventListener('DOMContentLoaded', () => { function positionAutocomplete() { if (!tagAutocomplete || !tagInput) return; const rect = tagInput.getBoundingClientRect(); - tagAutocomplete.style.top = `${rect.bottom + 4}px`; + const gap = 4; tagAutocomplete.style.left = `${rect.left}px`; tagAutocomplete.style.width = `${rect.width}px`; + + // Measure the dropdown height. Requires the element to be visible + // (callers add the `active` class before invoking this function). + const dropdownHeight = tagAutocomplete.offsetHeight; + + // If the Suggestions section sits below the input and the default + // downward dropdown would overlap it, flip the dropdown above the + // input so the user can still see their suggestion chips. + let flipUp = false; + if (suggestionsSection && suggestionsSection.offsetParent !== null) { + const suggestionsRect = suggestionsSection.getBoundingClientRect(); + const wouldOverlap = (rect.bottom + gap + dropdownHeight) > suggestionsRect.top; + const fitsAbove = (rect.top - gap - dropdownHeight) >= 0; + if (wouldOverlap && fitsAbove) flipUp = true; + } + + if (flipUp) { + tagAutocomplete.style.top = `${rect.top - gap - dropdownHeight}px`; + } else { + tagAutocomplete.style.top = `${rect.bottom + gap}px`; + } } function renderAutocomplete(tags) { @@ -642,8 +663,8 @@ document.addEventListener('DOMContentLoaded', () => { if (tags.length === 0) { tagAutocomplete.innerHTML = '
No matching tags
'; - positionAutocomplete(); tagAutocomplete.classList.add('active'); + positionAutocomplete(); return; } @@ -655,8 +676,8 @@ document.addEventListener('DOMContentLoaded', () => { ${t.kind || 'user'} `; }).join(''); - positionAutocomplete(); tagAutocomplete.classList.add('active'); + positionAutocomplete(); } function selectAutocompleteItem(index) { @@ -810,6 +831,12 @@ document.addEventListener('DOMContentLoaded', () => { function openModal(index) { modal.classList.add('active'); updateImage(index); + // Auto-focus the tag input on desktop so users can start typing immediately. + // Skipped on mobile — the on-screen keyboard would shove the image out of + // view and focus feels unexpected on touch. + if (tagInput && window.innerWidth >= 768) { + tagInput.focus(); + } history.replaceState({ modalIndex: index }, '', window.location.href); } @@ -978,6 +1005,37 @@ document.addEventListener('DOMContentLoaded', () => { toggleZoom(); }); + // Touch-swipe prev/next. Skipped when zoomed (drag-to-pan wins) or on + // multi-touch (pinch). Setting dragMoved suppresses the synthesized + // click that would otherwise toggle zoom after navigation. + const SWIPE_MIN_DISTANCE = 50; + const SWIPE_DOMINANCE = 1.5; + let touchStartX = null; + let touchStartY = null; + + modalImageWrapper.addEventListener('touchstart', (e) => { + if (isZoomed || e.touches.length !== 1) { + touchStartX = null; + return; + } + touchStartX = e.touches[0].clientX; + touchStartY = e.touches[0].clientY; + }, { passive: true }); + + modalImageWrapper.addEventListener('touchend', (e) => { + if (touchStartX === null) return; + const touch = e.changedTouches[0]; + const dx = touch.clientX - touchStartX; + const dy = touch.clientY - touchStartY; + touchStartX = null; + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + if (absDx < SWIPE_MIN_DISTANCE) return; + if (absDx < absDy * SWIPE_DOMINANCE) return; + dragMoved = true; + if (dx > 0) showPrev(); else showNext(); + }); + modalClose?.addEventListener('click', closeModal); modalPrev?.addEventListener('click', showPrev); modalNext?.addEventListener('click', showNext); diff --git a/app/templates/gallery.html b/app/templates/gallery.html index 572b62f..2918d42 100644 --- a/app/templates/gallery.html +++ b/app/templates/gallery.html @@ -144,6 +144,11 @@

Common tags across selected images:

+
+

Suggestions

+

Select 2+ images to see consensus suggestions.

+
+
{% if series_info %}

📚 Add to Series

@@ -179,91 +184,7 @@ - {% if series_info %} - + {% endif %} {% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 9396146..15ff437 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -19,14 +19,14 @@
-
-

System Overview

-
- - +
+

System Overview

+
+ +
-
+
- Total Images @@ -60,7 +60,7 @@

Monitor and control the Celery-based import queue system.

-
+
- Pending @@ -88,14 +88,14 @@
-