thoth-sphinx / app.py
beaunix's picture
Uplouad config files v2
af1cf6d verified
Raw
History Blame Contribute Delete
18.7 kB
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
from pathlib import Path
ROOT = Path(__file__).parent
sys.path.insert(0, str(ROOT / 'src'))
import gradio as gr
import llm
import ratelimit as RL
from llm import LOCATION_TYPES, PERIODS, SUPPORTS, TEXT_TYPES, TextContext
from pipeline import SphinxPipeline
# Backend selection. The Space runs the torch/ZeroGPU path against the v10 .pt.
# SPHINX_ONNX is a local-dev escape hatch (onnxruntime is not in
# requirements.txt) that lets the extraction be verified without torch — both
# backends letterbox identically and end in SL.postprocess_onnx, so the top-3
# slot contract the corrector depends on is preserved either way.
_ONNX = os.getenv('SPHINX_ONNX')
if _ONNX:
PIPELINE = SphinxPipeline(onnx_path=Path(_ONNX))
BACKEND = f'ONNX CPU ({Path(_ONNX).name})'
else:
import json
from infer_torch import WEIGHTS, make_torch_infer_fn
_class_names = list(json.load(
open(ROOT / 'src' / 'artifacts' / 'class_map50_v9.json')).keys())
PIPELINE = SphinxPipeline(infer_fn=make_torch_infer_fn(_class_names))
BACKEND = f'PyTorch ZeroGPU ({WEIGHTS.name})'
LAYOUTS = ['columns', 'rows']
DIRECTIONS = ['rtl', 'ltr']
def _codes_by_line(outer: dict) -> str:
"""
Group the corrected sequence into physical lines using boundary_hints —
the same cut semantics the LLM chunker uses, so what you read here is what
the model was shown.
"""
codes = outer['correction']['flat_corrected_seq']
bounds = sorted(b for b in outer.get('boundary_hints', []) if 0 < b <= len(codes))
if not bounds or bounds[-1] != len(codes):
bounds.append(len(codes))
lines, start = [], 0
for i, b in enumerate(bounds, 1):
if b > start:
lines.append(f'**{i:>2}.** ' + ' '.join(codes[start:b]))
start = b
return '\n\n'.join(lines) or '_no signs detected_'
def _cartouche_rows(cartouches: list[dict]) -> list[list]:
rows = []
for i, c in enumerate(cartouches):
interior = ' '.join(s[0][0] for s in c.get('slots') or [] if s)
if c.get('translit'):
rows.append([i, c['translit'], c.get('english') or '',
' '.join(c.get('spelling') or []),
f"{c.get('score', 0):.2f}",
'verified' if c.get('verified') else 'match'])
else:
rows.append([i, '—', 'no confident royal-name match',
interior, '—', 'REFUSED'])
return rows
def decode(
image, layout, direction, do_translate,
period, text_type, support, location_type, site, dynasty, kings_reign,
request: gr.Request,
progress=gr.Progress(),
):
if image is None:
raise gr.Error('Upload an image of a hieroglyphic inscription first.')
if not layout:
# Fragile breakpoint #10: the geometric auto-detector misvotes on real
# walls, so layout must come from the user.
raise gr.Error('Choose a layout — rows or columns. It cannot be '
'inferred reliably and a wrong guess scrambles the '
'reading order.')
progress(0.1, desc='Reading the wall…')
raw = PIPELINE.run(
image, # filepath — pipeline._load_image decodes it
direction = direction,
layout = layout,
use_enhance = False, # fragile breakpoint #9
annotate = True,
)
annotated = raw['annotated_bgr'][:, :, ::-1] # BGR -> RGB for Gradio
summary = (
f"**{raw['n_detections']}** signs · **{raw['n_cartouches']}** cartouches · "
f"layout `{raw['layout']}` · direction `{raw['direction']}` \n"
f"<sub>{BACKEND}</sub>"
)
codes_md = _codes_by_line(raw['outer'])
cart_rows = _cartouche_rows(raw['cartouches'])
local_tl = raw['outer']['correction'].get('flat_translit') or ''
translit_md = ('_Transliteration is off. Enable the toggle to send the '
'detected signs to GPT-4o for a scholarly reading._')
gloss_md = ''
if do_translate:
allowed, left, msg = RL.check(request)
if not allowed:
translit_md = f'⚠️ {msg}'
elif not llm.SERVICE.enabled:
translit_md = ('⚠️ No `OPENAI_API_KEY` secret is configured on this '
'Space, so the GPT stage is unavailable. Detection '
'results above are unaffected.')
else:
progress(0.6, desc='Consulting the scribe (GPT-4o)…')
ctx = TextContext(
period=period, text_type=text_type, support=support,
location_type=location_type, site=site, dynasty=dynasty,
kings_reign=kings_reign,
)
out = llm.SERVICE.transliterate(raw, ctx)
if out.error and not out.chunks:
translit_md = f'⚠️ {out.error}'
else:
left = RL.commit(request) # only successful calls consume
translit_md = out.full_transliteration or '_(empty)_'
gloss_md = out.full_translation or ''
notes = [c.linguistic_notes for c in out.chunks if c.linguistic_notes]
if notes:
gloss_md += '\n\n**Notes.** ' + ' '.join(notes)
tail = f'\n\n<sub>{out.model} · {out.n_chunks} segment(s) · ' \
f'{left} transliteration(s) left today</sub>'
gloss_md += tail
if out.error:
gloss_md += f'\n\n⚠️ Partial result: {out.error}'
return annotated, summary, codes_md, cart_rows, local_tl, translit_md, gloss_md
# ---------------------------------------------------------------- theme ----
# Palette lifted verbatim from the React frontend so the Space and the web app
# read as one product:
# frontend/tailwind.config.js egypt-gold #d4a64a · egypt-gold-dark #8a6a20
# egypt-sand #c89b5a · egypt-stone #3a2a14
# frontend/src/index.css body #1c1208 on text #fde68a, gold gradient
# #fde68a -> #f59e0b -> #b45309, Cinzel titles
GOLD = '#d4a64a' # egypt-gold
GOLD_DARK = '#8a6a20' # egypt-gold-dark
SAND = '#c89b5a' # egypt-sand
STONE = '#3a2a14' # egypt-stone (light brown panels)
BROWN_DEEP = '#1c1208' # frontend body background
BROWN_MID = '#241706' # between body and panel
DESERT = '#fde68a' # desert-yellow body text
AMBER = '#fbbf24' # heading gold
AMBER_DEEP = '#b45309' # gradient foot
THEME = gr.themes.Base(
primary_hue='amber',
secondary_hue='yellow',
neutral_hue='stone',
font=['Inter', 'system-ui', 'sans-serif'],
).set(
# canvas + panels: deep brown -> light brown, gold-framed
body_background_fill = BROWN_DEEP,
body_text_color = DESERT,
body_text_color_subdued = SAND,
background_fill_primary = BROWN_MID,
background_fill_secondary = STONE,
block_background_fill = BROWN_MID,
block_border_color = 'rgba(245,158,11,0.70)', # amber-500/70
block_border_width = '2px',
block_radius = '16px', # rounded-2xl
block_label_background_fill = STONE,
block_label_text_color = AMBER,
block_label_border_color = 'rgba(245,158,11,0.45)',
block_title_text_color = AMBER,
panel_background_fill = BROWN_MID,
panel_border_color = 'rgba(245,158,11,0.55)',
border_color_primary = 'rgba(245,158,11,0.45)',
border_color_accent = GOLD,
color_accent = GOLD,
color_accent_soft = 'rgba(212,166,74,0.18)',
# inputs
input_background_fill = '#17100a',
input_border_color = 'rgba(212,166,74,0.45)',
input_border_color_focus = AMBER,
input_placeholder_color = 'rgba(200,155,90,0.65)',
# primary button: the desert-gold gradient from index.css
button_primary_background_fill = f'linear-gradient(180deg, {DESERT} 0%, #f59e0b 55%, {AMBER_DEEP} 100%)',
button_primary_background_fill_hover = f'linear-gradient(180deg, #fff3c4 0%, {AMBER} 55%, #92400e 100%)',
button_primary_text_color = '#2b1a06',
button_primary_border_color = GOLD,
button_secondary_background_fill= STONE,
button_secondary_text_color = DESERT,
button_secondary_border_color = 'rgba(212,166,74,0.5)',
# checkbox / radio
# NB: checkbox uses *_background_color, not *_background_fill like blocks
checkbox_background_color = '#17100a',
checkbox_background_color_selected = GOLD,
checkbox_border_color = 'rgba(212,166,74,0.6)',
checkbox_border_color_focus = AMBER,
checkbox_label_background_fill = STONE,
checkbox_label_background_fill_selected = f'linear-gradient(180deg, {GOLD} 0%, {GOLD_DARK} 100%)',
checkbox_label_text_color = DESERT,
checkbox_label_border_color = 'rgba(212,166,74,0.4)',
# cartouche table
table_border_color = 'rgba(212,166,74,0.35)',
table_even_background_fill = BROWN_MID,
table_odd_background_fill = '#2a1c0d',
link_text_color = AMBER,
link_text_color_hover = DESERT,
slider_color = GOLD,
)
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Inter:wght@400;500;600;700&display=swap');
.gradio-container { max-width: 1280px !important; }
/* Cinzel for titles, matching frontend .font-serif */
#sphinx-hero h1, .sphinx-panel .label-wrap span, label span, h1, h2, h3 {
font-family: 'Cinzel', 'Trajan Pro', Georgia, serif !important;
letter-spacing: 0.04em;
}
/* Hero: gold-gradient wordmark on a sand-lit brown band */
#sphinx-hero {
border: 2px solid rgba(245,158,11,0.70);
border-radius: 16px;
padding: 18px 22px;
background:
radial-gradient(120% 160% at 8% 0%, rgba(212,166,74,0.20) 0%, rgba(28,18,8,0) 60%),
linear-gradient(180deg, #2a1c0d 0%, #1c1208 100%);
}
#sphinx-hero h1 {
margin: 0 0 .25rem 0;
font-size: 2rem;
background: linear-gradient(180deg, #fde68a 0%, #f59e0b 55%, #b45309 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 1px 1px rgba(0,0,0,.55));
}
#sphinx-hero p { color: #c89b5a; margin: 0; }
/* Thick gold frames on the two working columns */
.sphinx-panel {
border: 2px solid rgba(245,158,11,0.55) !important;
border-radius: 16px !important;
background: linear-gradient(180deg, #241706 0%, #1c1208 100%) !important;
padding: 14px !important;
}
/* Result typography: gold headings, desert-yellow body */
.sphinx-result strong { color: #fcd34d; }
.sphinx-result code {
background: rgba(120,53,15,0.35);
color: #fde68a;
border-radius: 4px; padding: .1em .35em;
}
#sphinx-translit {
border-left: 3px solid rgba(212,166,74,0.75);
padding: .6rem .9rem;
background: rgba(58,42,20,0.35);
border-radius: 0 12px 12px 0;
font-size: 1.05rem;
color: #fde68a;
}
/* Drop zone: dashed gold frame on sun-warmed brown, lighting up on hover */
#sphinx-drop {
border: 2px dashed rgba(212,166,74,0.75) !important;
border-radius: 16px !important;
background:
radial-gradient(120% 120% at 50% 0%, rgba(212,166,74,0.14) 0%, rgba(28,18,8,0) 70%),
#17100a !important;
transition: border-color .18s ease, box-shadow .18s ease;
}
#sphinx-drop:hover {
border-color: #fbbf24 !important;
box-shadow: 0 0 0 4px rgba(251,191,36,0.12), 0 0 22px rgba(212,166,74,0.28);
}
/* Gradio marks the active drag state on the inner upload target */
#sphinx-drop .drag-active,
#sphinx-drop [data-testid="block-label"] + div.drag-active {
border-color: #fde68a !important;
background: rgba(212,166,74,0.16) !important;
}
#sphinx-drop .wrap { color: #c89b5a !important; } /* "drop image here" */
#sphinx-drop svg { color: #d4a64a !important; opacity: .9; }
/* Detections frame: solid gold, sits like a mounted plate */
#sphinx-detections {
border: 2px solid rgba(245,158,11,0.85) !important;
border-radius: 16px !important;
background: #14100c !important;
box-shadow: inset 0 0 24px rgba(0,0,0,.55);
}
/* Desert-gold scrollbars + focus ring (mirrors index.css) */
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: rgba(120,53,15,0.15); }
*::-webkit-scrollbar-thumb { background: rgba(217,119,6,0.55); border-radius: 9999px; }
*::-webkit-scrollbar-thumb:hover { background: rgba(251,191,36,0.75); }
:focus-visible { outline: 2px solid rgba(251,191,36,0.6); outline-offset: 2px; }
footer { display: none !important; }
"""
with gr.Blocks(title='SphinxEyes — Hieroglyph Decoder',
theme=THEME, css=CSS) as demo:
gr.Markdown(
'# 𓂀 SphinxEyes\n'
'<p>Detection → reading order → lexicon correction → cartouche '
'matching for Middle Egyptian hieroglyphs. Detection is free and '
'unlimited; the optional GPT-4o transliteration is capped at '
f'<b>{RL.PER_IP_DAILY} per day</b>.</p>',
elem_id='sphinx-hero',
)
with gr.Row():
with gr.Column(scale=1, elem_classes='sphinx-panel'):
gr.Markdown('### 𓉘 1 · Drop your inscription',
elem_classes='sphinx-result')
# Gradio's Image component IS the drop zone: dragging a file
# anywhere over it uploads. sources= keeps upload + clipboard paste
# and drops the webcam, which makes no sense for wall photographs.
image = gr.Image(
type='filepath', # pipeline._load_image decodes
label='Drag & drop an image here — or click to browse',
sources=['upload', 'clipboard'],
height=340,
elem_id='sphinx-drop',
show_download_button=False,
)
layout = gr.Radio(
LAYOUTS, label='Layout (required)',
info='How the text is arranged. Cannot be auto-detected '
'reliably — a wrong choice scrambles the reading order.',
value=None)
direction = gr.Radio(
DIRECTIONS, value='rtl', label='Reading direction',
info='Geometrically undecidable; right-to-left is the default. '
'Signs normally face the start of the line.')
translate = gr.Checkbox(
value=False, # default OFF — this stage costs money
label='Transliterate with GPT-4o',
info=f'Off by default. Limited to {RL.PER_IP_DAILY} runs per '
f'day per visitor.')
with gr.Accordion('𓊹 Archaeological context (optional, improves '
'the reading)', open=False):
gr.Markdown('<sub>Every field defaults to *unknown*. The model '
'is told not to invent what you leave unset, but '
'exploits whatever you do supply.</sub>')
period = gr.Dropdown(PERIODS, value='unknown', label='Period')
text_type = gr.Dropdown(TEXT_TYPES, value='unknown', label='Text type')
support = gr.Dropdown(SUPPORTS, value='unknown', label='Physical support')
location_type = gr.Dropdown(LOCATION_TYPES, value='unknown', label='Location type')
site = gr.Textbox(value='unknown', label='Site', placeholder='e.g. Karnak')
dynasty = gr.Textbox(value='unknown', label='Dynasty', placeholder='e.g. XVIII')
kings_reign = gr.Textbox(value='unknown', label="King's reign",
placeholder='e.g. Thutmose III')
run = gr.Button('𓂀 Decode', variant='primary', size='lg')
with gr.Column(scale=1, elem_classes='sphinx-panel'):
gr.Markdown('### 𓊪 2 · Detections', elem_classes='sphinx-result')
# Annotated pass of the custom YOLO model: gold boxes = cartouches,
# green = signs, grey = unknown (drawn by pipeline._draw_detections).
annotated = gr.Image(
label='Your YOLO model’s bounding boxes',
height=420,
elem_id='sphinx-detections',
show_download_button=True, # save the annotated wall
show_label=True,
interactive=False,
)
summary = gr.Markdown(elem_classes='sphinx-result')
# Swatches mirror _draw_detections' BGR constants exactly:
# cartouche (0,190,255) · sign (80,200,60) · unknown (160,160,160)
gr.Markdown(
'<sub><span style="color:#ffbe00">▬</span> cartouche · '
'<span style="color:#3cc850">▬</span> sign · '
'<span style="color:#a0a0a0">▬</span> unknown</sub>')
with gr.Accordion('Detected signs by line', open=True):
codes = gr.Markdown(elem_classes='sphinx-result')
cartouches = gr.Dataframe(
headers=['#', 'Transliteration', 'King', 'Interior signs',
'Score', 'Status'],
label='Royal cartouches', wrap=True, interactive=False)
with gr.Accordion('Local lexicon reading (no LLM)', open=False):
local = gr.Textbox(label='Corrector transliteration', lines=3,
show_copy_button=True)
translit = gr.Markdown(elem_id='sphinx-translit')
gloss = gr.Markdown(elem_classes='sphinx-result')
run.click(
decode,
inputs=[image, layout, direction, translate, period, text_type, support,
location_type, site, dynasty, kings_reign],
outputs=[annotated, summary, codes, cartouches, local, translit, gloss],
concurrency_limit=1, # one ZeroGPU slot
)
gr.Markdown(
'<sub>The daily cap is best-effort cost control, not security: it is '
'in-memory and per-IP, and resets when the Space restarts. Detector: '
'YOLO11l, 150 Gardiner classes. Substitution priors come from the v9 '
'confusion matrix — one generation behind the v10 weights, which share '
'v9\'s exact class ordering.</sub>'
)
if __name__ == '__main__':
demo.queue(max_size=16).launch()