#!/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"{BACKEND}" ) 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{out.model} · {out.n_chunks} segment(s) · ' \ f'{left} transliteration(s) left today' 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' '

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'{RL.PER_IP_DAILY} per day.

', 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('Every field defaults to *unknown*. The model ' 'is told not to invent what you leave unset, but ' 'exploits whatever you do supply.') 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( ' cartouche · ' ' sign · ' ' unknown') 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( '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.' ) if __name__ == '__main__': demo.queue(max_size=16).launch()