Spaces:
Running on Zero
Running on Zero
| #!/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() | |