{% extends "base.html" %} {% macro chosen_icon() -%} {%- endmacro %} {% block title %}Import scans · foilstack{% endblock %} {% block status %}{% if active %}import running · {{ active.processed }}/{{ active.total }}{% elif counts.all %}{{ counts.all }} scans waiting · {{ counts.nomatch }} with no match{% else %}queue clear · drop some scans to begin{% endif %}{% endblock %} {% block screen %}
Import scans jpg, png, tif · or a .zip of them
Drop your card scans here
one image per card, or a .zip of them
up to {{ "{:,}".format(max_images) }} scans · {{ max_mb }} MB per upload
{{ active.filename if active }}
{{ 'matching…' if active }}
{{ 'queued' if active else 'ready' }} 0%
scans
{{ active.total if active else 0 }}
matched
{{ active.processed if active else 0 }}
waiting
{{ counts.all }}
Match settings
Default condition
{% for c in conditions %} {% endfor %}
Default finish
Auto-accept above
{% for t in thresholds %} {% endfor %}

Finish is yours to set, not the matcher's: a foil and a non-foil printing share artwork exactly and differ under the light, so the scan cannot settle it — and the price gap is routinely a multiple.

A match is accepted without review only if it clears that score, beats the runner-up by a margin, and — where the runner-up is the same card in another printing — their prices are close. Reprints share artwork, so a high score alone is not evidence. The threshold is stored with the import, so it stays clear later why a scan went through.

matcher: dinov3 image embeddings · nearest neighbour
scans kept on disk · never uploaded off-host
{% if jobs %}
Recent imports
{% for job in jobs %}
{{ job.created_at.strftime('%b %d %H:%M') }}   {{ job.filename[:22] }}   {{ job.status }} {{ job.processed }}/{{ job.total }}
{% endfor %}
{% endif %}

Matches

{% if queue_value %}{{ queue_value|money }} market value{% endif %}
{% for r in rows %}
scan
{{ r.filename }}
{% if r.card_id %}{% endif %} catalogue
{{ r.name }}
{{ r.meta }}
{# No one-click swap on a row someone has already decided: that button commits on the spot, and putting a commit-to-the-guess control beside a deliberate choice is a trap. The panel behind "wrong card?" still lists every candidate, and picking there does not commit. #} {% if r.needs_review and r.alternates and not r.chosen %} {% else %}{{ r.alt }}{% endif %} {% if r.needs_review %} {% endif %}
{% if r.chosen %}{{ chosen_icon() }}{% else %}
{{ r.pct }} match
{% endif %}
{% for c in conditions[:4] %} {% endfor %}
{% if r.ambiguous %} ? {% endif %}
{% if r.needs_review and r.card_id %} {% endif %}
{# Full-width, so it wraps onto its own line inside the flex row rather than squeezing the columns beside it. Filled on first open. #}
{% endfor %} {% if not rows %}
{% if filter == 'matched' %}nothing matched is waiting {% elif filter == 'nomatch' %}everything waiting has a match {% else %}queue is clear — drop some scans to start{% endif %}
{% endif %}
{% endblock %} {% block scripts %} {% endblock %}