${auth.flag === 'likely_not_genuine'
+ ? 'Possibly not a genuine card' : 'Worth double-checking'}
+ ${auth.observation ? `
${esc(auth.observation)}
` : ''}
+
` : '';
+
return `
+ ${authBanner}
A photo can't show surface scratches, print lines, or light edge wear the
way a grader's raking light does — and a seller's listing photo is often lit to hide them.
Treat this as a rough screen, not a prediction of what it comes back as.
@@ -398,13 +407,21 @@ function renderHistory() {
const rows = state.history;
$('#history-count').textContent = rows.length ? `${rows.length} graded` : '';
$('#history-empty').hidden = rows.length > 0;
- $('#history-body').innerHTML = rows.map((g) => `
+ $('#history-body').innerHTML = rows.map((g) => {
+ const auth = g.authenticity;
+ // A dot rather than repeating the full banner text — the row is
+ // already tight, and opening the card gives the real explanation.
+ const authDot = (auth && auth.flag !== 'none')
+ ? ``
+ : '';
+ return `
`).join('');
+ `;
+ }).join('');
}
/* ----------------------------------------------------------------- spend */
diff --git a/static/style.css b/static/style.css
index b3f6ef7..a472f91 100644
--- a/static/style.css
+++ b/static/style.css
@@ -422,6 +422,43 @@ textarea { resize: vertical; min-height: 64px; }
margin-top: 0; margin-bottom: 14px;
}
+/* --------------------------------------------------------- authenticity */
+/* A concern about whether the card is what it claims to be, not its
+ condition — deliberately styled as a full banner rather than a hint line
+ like every other measurement caveat, so it reads as "stop and look at
+ this" before anything about the grade itself. Two tiers, both using
+ --critical rather than --brand: this is exactly the "distrust this"
+ signal that colour is reserved for elsewhere in the app, and the brand
+ red would blur into decoration here of all places. */
+.authenticity-banner {
+ border: 1px solid color-mix(in srgb, var(--critical) 45%, transparent);
+ background: color-mix(in srgb, var(--critical) 10%, var(--surface-1));
+ border-radius: 8px;
+ padding: 11px 14px;
+ margin-bottom: 14px;
+}
+.authenticity-check {
+ border-color: color-mix(in srgb, var(--warning) 55%, transparent);
+ background: color-mix(in srgb, var(--warning) 12%, var(--surface-1));
+}
+.authenticity-title {
+ font-weight: 700; font-size: 13.5px;
+ color: var(--critical);
+}
+.authenticity-check .authenticity-title { color: var(--warning); }
+.authenticity-detail {
+ font-size: 12.5px; color: var(--ink-2); margin-top: 3px; line-height: 1.5;
+}
+
+/* History-row version: a plain dot, since the row has no room for the full
+ banner text and opening the card gives the real explanation. */
+.authenticity-dot {
+ display: inline-block; width: 8px; height: 8px; border-radius: 50%;
+ margin-right: 6px; vertical-align: middle;
+ background: var(--critical);
+}
+.authenticity-dot.authenticity-check { background: var(--warning); }
+
/* -------------------------------------------------------------- banner */
.banner {
diff --git a/store.py b/store.py
index 2098a00..7e0643a 100644
--- a/store.py
+++ b/store.py
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS grades (
grade_high INTEGER,
confidence TEXT,
categories_json TEXT,
+ authenticity_json TEXT,
edge_measurements_json TEXT,
centering_measurement_json TEXT,
aspect_measurement_json TEXT,
@@ -126,6 +127,7 @@ def init():
for statement in (
"ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT",
"ALTER TABLE grades ADD COLUMN source_images_json TEXT",
+ "ALTER TABLE grades ADD COLUMN authenticity_json TEXT",
):
try:
conn.execute(statement)
@@ -219,11 +221,12 @@ def save_grade(grade, thumbnail=None, label=None, source_images=None):
"(created_at, label, card_type, card_note, image_count, thumbnail, "
" model, estimated_grade, "
" grade_low, grade_high, confidence, categories_json, "
+ " authenticity_json, "
" edge_measurements_json, centering_measurement_json, "
" aspect_measurement_json, "
" limitations_json, note, estimated_cost, usage_json, "
" source_images_json) "
- "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
now(), label, grade.get("card_type"), grade.get("card_note"),
grade.get("image_count"), thumbnail,
@@ -231,6 +234,7 @@ def save_grade(grade, thumbnail=None, label=None, source_images=None):
grade.get("estimated_grade"), grade.get("grade_low"),
grade.get("grade_high"), grade.get("confidence"),
json.dumps(grade.get("categories")),
+ json.dumps(grade.get("authenticity")),
json.dumps(grade.get("edge_measurements")),
json.dumps(grade.get("centering_measurement")),
json.dumps(grade.get("aspect_measurement")),
@@ -264,6 +268,7 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None):
grade.get("estimated_grade"), grade.get("grade_low"),
grade.get("grade_high"), grade.get("confidence"),
json.dumps(grade.get("categories")),
+ json.dumps(grade.get("authenticity")),
json.dumps(grade.get("edge_measurements")),
json.dumps(grade.get("centering_measurement")),
json.dumps(grade.get("aspect_measurement")),
@@ -274,7 +279,7 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None):
sql = (
"UPDATE grades SET created_at=?, card_type=?, card_note=?, "
"image_count=?, model=?, estimated_grade=?, grade_low=?, "
- "grade_high=?, confidence=?, categories_json=?, "
+ "grade_high=?, confidence=?, categories_json=?, authenticity_json=?, "
"edge_measurements_json=?, centering_measurement_json=?, "
"aspect_measurement_json=?, limitations_json=?, note=?, "
"estimated_cost=?, usage_json=?"
@@ -404,7 +409,7 @@ def get_grade_images(grade_id):
def _row_to_grade(row):
d = dict(row)
- for key in ("categories_json", "edge_measurements_json",
+ for key in ("categories_json", "authenticity_json", "edge_measurements_json",
"centering_measurement_json", "aspect_measurement_json",
"limitations_json", "usage_json"):
out_key = key[:-len("_json")]
@@ -429,9 +434,10 @@ def _row_to_grade(row):
_LIST_COLUMNS = (
"id, created_at, label, card_type, card_note, image_count, thumbnail, "
"model, estimated_grade, grade_low, grade_high, confidence, "
- "categories_json, edge_measurements_json, centering_measurement_json, "
- "aspect_measurement_json, limitations_json, note, estimated_cost, "
- "usage_json, (source_images_json IS NOT NULL) AS source_images_json"
+ "categories_json, authenticity_json, edge_measurements_json, "
+ "centering_measurement_json, aspect_measurement_json, limitations_json, "
+ "note, estimated_cost, usage_json, "
+ "(source_images_json IS NOT NULL) AS source_images_json"
)
diff --git a/vision.py b/vision.py
index e93693c..f02dec0 100644
--- a/vision.py
+++ b/vision.py
@@ -521,6 +521,51 @@ the untouched crop too.
On chrome and refractor stock, expect fine scratching; it is the norm rather \
than the exception, and its absence is what is notable.
+AUTHENTICITY — separate from all four categories above, and separate from \
+condition entirely. A pristine card that isn't a genuine product doesn't get \
+a PSA grade at all; PSA authenticates before it grades, and a card that fails \
+that step never reaches the 1-10 scale regardless of how clean it looks. \
+Judge this from what's actually visible, the same discipline as everything \
+else here — flag it when you see a real, specific tell, not as a hedge.
+ Signs the card may not be an official print at all: a set, era, or \
+name/number combination that doesn't correspond to any real printing you \
+recognize; card text in a font, layout, or language inconsistent with the \
+publisher and era it's claiming to be; artwork that reads as fan-made or \
+AI-generated rather than the actual official illustration for that card; \
+visible printing quality far below what mass-produced card stock looks like \
+— soft/pixelated detail, visible inkjet or laser-printer banding, a paper \
+stock that looks like cardstock from a home printer rather than the glossy, \
+consistent stock real trading cards use; or the word "proxy," "custom," \
+"fan art," or similar printed anywhere on the card itself.
+ Signs a real card might be a counterfeit of a genuine printing: colour \
+saturation, font weight, or line quality that doesn't match known genuine \
+copies of that exact card; a holo or foil pattern that looks wrong for the \
+set (wrong pattern entirely, or applied where the real printing doesn't have \
+one); a cardback design, layout, or copyright text that doesn't match the \
+genuine card. Weigh these against your own uncertainty — you're comparing \
+against a memory of what genuine copies look like, not a reference image, so \
+reserve likely_not_genuine for cases where something is clearly, specifically \
+wrong, not just "this looks a little different than I'd expect."
+ TRIMMING is the physical-alteration version of this same concern, already \
+covered under CORNERS above — a genuine card that's been cut down to fake \
+sharper edges than it actually has. When you flag trimming there, also set \
+this field (worth_checking is usually right, since a photo can't confirm it \
+the way calipers can) so it surfaces in one place rather than only inside a \
+corners observation.
+ flag "none" is the ordinary case and should be the overwhelming majority \
+of cards — most cards people photograph for grading are exactly what they \
+appear to be, and reaching for suspicion without a specific tell is its own \
+kind of dishonesty, the same failure as inventing wear that isn't there. Use \
+"worth_checking" when you see something specific but can't be confident from \
+a photo alone — say what you saw. Use "likely_not_genuine" only when you're \
+reasonably confident, not just uneasy — a fictional set name, visible \
+"proxy" text, or artwork that plainly isn't the real official print are the \
+kind of thing that earns this level; a card that's merely unfamiliar to you \
+does not.
+ If you flag likely_not_genuine, say so in note and reflect it in the grade: \
+estimated_grade should be null (this isn't a card PSA would assign a number \
+to at all), with the reasoning in note rather than in a numeric range.
+
HOW PSA COMBINES THE FOUR
The grade is capped by the WORST attribute, not averaged across them. Three \
@@ -670,6 +715,25 @@ GRADE_CATEGORY_SCHEMA = {
"additionalProperties": False,
}
+AUTHENTICITY_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "flag": {
+ "type": "string",
+ "enum": ["none", "worth_checking", "likely_not_genuine"],
+ "description": "Whether anything about this card's legitimacy, "
+ "not its condition, is in question.",
+ },
+ "observation": {
+ "type": "string",
+ "description": "The specific thing that prompted the flag — "
+ "empty string when flag is 'none'.",
+ },
+ },
+ "required": ["flag", "observation"],
+ "additionalProperties": False,
+}
+
GRADING_SCHEMA = {
"type": "object",
"properties": {
@@ -682,6 +746,7 @@ GRADING_SCHEMA = {
"type": "string",
"description": "One line naming the card if legible (player/name, set, year) — for the history list.",
},
+ "authenticity": AUTHENTICITY_SCHEMA,
"estimated_grade": {
"type": ["integer", "null"],
"description": "Best single estimate, whole number 1-10, or null if ungradeable from these photos.",
@@ -699,9 +764,9 @@ GRADING_SCHEMA = {
},
"note": {"type": "string", "description": "Short overall summary."},
},
- "required": ["card_type", "card_note", "estimated_grade", "grade_low",
- "grade_high", "confidence", "centering", "corners", "edges",
- "surface", "limitations", "note"],
+ "required": ["card_type", "card_note", "authenticity", "estimated_grade",
+ "grade_low", "grade_high", "confidence", "centering", "corners",
+ "edges", "surface", "limitations", "note"],
"additionalProperties": False,
}
@@ -720,6 +785,14 @@ def _clean_category(raw):
return {"severity": severity, "observation": raw.get("observation") or ""}
+def _clean_authenticity(raw):
+ raw = raw if isinstance(raw, dict) else {}
+ flag = raw.get("flag")
+ if flag not in ("none", "worth_checking", "likely_not_genuine"):
+ flag = "none"
+ return {"flag": flag, "observation": raw.get("observation") or ""}
+
+
def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True):
"""Estimate the PSA grade a card would likely receive, from photo(s).
@@ -924,6 +997,7 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True)
"closeups": len(crops),
"card_type": card_type,
"card_note": (parsed.get("card_note") or "").strip(),
+ "authenticity": _clean_authenticity(parsed.get("authenticity")),
"edge_measurements": measured,
"centering_measurement": centering,
"aspect_measurement": aspect,