CoolFace
Apppublic

moebiusT7/book-ocr-studio

sourceHugging Faceagpl-3.0updated 2d agoView on Hugging Face
0likes
app.py179 linesDownload Raw Back to root
1from pathlib import Path2import os3os.umask(0o077)4import streamlit as st5from display_language import english_message, diagnostic_display6from core import JOBS, ROOT, new_job, launch, read, save, is_running, accept, bundle7from download_names import book_details, markdown_name8 9from chrome_job import ensure_bridge10if os.environ.get('BOOK_OCR_ENABLE_BRIDGE')=='1':11    st.cache_resource(ensure_bridge)()12 13st.set_page_config(page_title='Book OCR Studio',page_icon='📖',layout='wide')14st.markdown('''<style>[data-testid="stStatusWidget"]{display:none!important}.block-container{max-width:1500px;padding-top:2rem}h1{letter-spacing:-.04em}textarea:disabled{-webkit-text-fill-color:#334155!important;color:#334155!important;opacity:1!important}div[data-testid="stMetric"]{background:#f1f5f9;border-radius:10px;padding:12px}</style>''',unsafe_allow_html=True)15st.title('Book OCR Studio')16st.subheader('Local-first book OCR')17st.caption('Local processing by default. Import PDFs, images or Kindle captures; export MD, HTML, PDF and EPUB with Gemma review or an optional vision API.')18st.caption('Output preserves the language of the source; it is not translated. Recognition accuracy varies by language and layout.')19with st.sidebar:20    st.subheader('Local processing')21    st.write('Marker / YomiToku + Gemma 4 (12B default)')22    st.caption('Default Gemma review is local. The optional API connector sends images and OCR text to your selected endpoint only when enabled. Internet is needed for initial downloads and Kindle access.')23    st.divider()24    jobs=sorted(JOBS.glob('*/job.json'),reverse=True) if JOBS.exists() else []25    selected=st.selectbox('Saved jobs',['New job']+[p.parent.name for p in jobs])26    if selected!='New job': st.session_state['job']=str(JOBS/selected)27    st.caption('Original OCR is preserved. Model suggestions enter the reviewed version only after approval.')28 29if not st.session_state.get('c1_12b_default_applied'):30    st.session_state['shared_model']='gemma4:12b-it-qat'31    st.session_state['c1_12b_default_applied']=True32model=st.selectbox('Gemma review model — Möbius Custom C1 for OCR',['gemma4:12b-it-qat','gemma4:26b-a4b-it-qat'],key='shared_model')33st.caption('Review profile: Möbius Custom C1 for OCR (PDF, images and Kindle). 12B is the default; 26B uses the same profile.')34st.caption('12B + YomiToku supports parallel OCR and review on one GPU. A 16GB or larger card is recommended, with at least 14.5GB free VRAM. Select One GPU, parallel in GPU mode; speed gains depend on the input.')35st.caption('Timing: on one 16GB GPU, 26B runs OCR and review sequentially. This may take longer than 12B in single-GPU parallel mode. Timing varies with text volume and free VRAM; the end-to-end ratio has not been measured.')36from connector_ui import render as render_connector37review_options=render_connector(model)38engines=['Marker']39if (ROOT/'.venv-yomitoku/bin/python').exists():engines.append('YomiToku')40engine=st.selectbox('OCR engine (PDF, images and Kindle)',engines,key='shared_ocr_engine',help='Marker is the default. YomiToku is optional and has non-commercial terms unless separately licensed.')41if engine=='YomiToku':42    st.warning('YomiToku 0.15.0 code and weights: CC BY-NC-SA 4.0. Use only when your use is permitted by those terms or covered by a separate commercial license. Selecting it does not grant a license.')43else:44    st.caption('Marker 1.10.2 / Surya 0.17.1: GPL code; model weights have separate modified AI Pubs Open RAIL-M conditions. Marker is not an unrestricted commercial-use alternative. See Licenses and source below.')45with st.expander('Licenses and source'):46    st.markdown((ROOT/'THIRD_PARTY_NOTICES.md').read_text(encoding='utf-8'))47    st.download_button('Application license (AGPL-3.0-only)',(ROOT/'LICENSE').read_bytes(),file_name='LICENSE.txt',key='license-download')48upload_tab, kindle_tab=st.tabs(['PDF / Images','Kindle capture'])49with upload_tab:50    page_scope=st.radio('Pages to process',['Select a page range','All pages'],horizontal=True)51    all_pages=page_scope=='All pages'52    with st.form('upload'):53        uploads=st.file_uploader('One PDF or multiple page images',type=['pdf','png','jpg','jpeg','webp'],accept_multiple_files=True)54        a,b,c=st.columns(3)55        with a: spec=st.text_input('Page range','1-3',disabled=all_pages,help='Page numbers start at 1, for example 1-3,5. This field is ignored for All pages. Images are sorted by filename.')56        with b: mode=st.selectbox('GPU mode',['Automatic','Two GPUs in parallel','One GPU, sequential','One GPU, parallel (12B + YomiToku)'])57        with c:58            gemma=st.checkbox('Review against images with selected model',value=True)59            force=st.checkbox('Force OCR on text-based PDFs',value=False)60        st.caption('All input pages will be processed.' if all_pages else 'Only the selected pages will be processed, for example 1-3,5.')61        submitted=st.form_submit_button('Start conversion',type='primary',disabled=not st.session_state.get('review_ready',True))62    if submitted:63        try:64            files=sorted([(u.name,u.getvalue()) for u in uploads],key=lambda x:x[0])65            job=new_job(files,'' if all_pages else spec,gemma,force,{'Automatic':'auto','Two GPUs in parallel':'dual','One GPU, sequential':'sequential','One GPU, parallel (12B + YomiToku)':'shared'}[mode],ocr_engine=engine.lower(),**review_options)66            st.session_state['job']=str(job);launch(job)67        except Exception as exc:st.error(str(exc))68with kindle_tab:69    import kindle_ui,importlib70    importlib.reload(kindle_ui)71    kindle_ui.render_kindle()72 73from ui_refresh import adaptive_fragment,job_pending74 75@adaptive_fragment(3,lambda: job_pending(st.session_state.get('job')))76def show_job():77    if 'job' not in st.session_state:return78    job=Path(st.session_state['job']);cfg=read(job/'job.json');s=read(job/'status.json')79    st.divider();st.subheader(cfg['inputs'][0]['name'])80    st.caption(f'Saved to: {job}')81    st.caption(f"OCR engine: {cfg.get('ocr_engine','marker')}")82    st.caption(f"Review model: {cfg.get('model')} · Provider: {'OpenAI-compatible API' if cfg.get('review_connector') else 'Local Gemma'}")83    st.info(english_message(s['message']))84    if (job/'fallback.json').exists():85        with st.expander('VRAM fallback history'):86            for event in read(job/'fallback.json'):87                st.write(f"{event['event']} → {event['mode']}: {english_message(event['reason'])}")88    if (job/'execution.json').exists():89        execution=read(job/'execution.json')90        st.caption(f"Execution mode: {execution['mode']} / OCR: GPU {execution['ocr_gpu']} / Gemma: GPU {execution['gemma_gpu']}")91        st.caption(english_message(execution.get('reason','')))92        with st.expander('GPU assignment details'):93            st.json(diagnostic_display(execution))94    complete=sum((job/f'page-{i+1:05d}'/'marker.json').exists() for i in cfg['selected'])95    checked=sum((job/f'page-{i+1:05d}'/'review.json').exists() for i in cfg['selected'])96    a,b,c=st.columns(3)97    a.metric('OCR completed',f"{complete} / {len(cfg['selected'])}");b.metric('Gemma reviewed',checked);c.metric('Total input pages',cfg['total_pages'])98    st.caption('Progress covers the selected input only. It does not confirm full-book capture or error-free text.')99    running=is_running(job)100    if running:101        if st.button('Stop after current request',key='cancel'+job.name):102            (job/'cancel').touch();st.warning('Processing will stop after the current Gemma response.')103    elif s['state'] not in {'done'}:104        resume_models=['gemma4:12b-it-qat','gemma4:26b-a4b-it-qat']105        resume_model=st.selectbox('Review model for resume',resume_models,index=resume_models.index(cfg['model']),key='resumemodel'+job.name)106        shared_resume=st.checkbox('Resume in single-GPU parallel mode (12B + YomiToku only)',value=cfg.get('mode')=='shared',key='resumeshared'+job.name)107        st.caption('Completed reviews are preserved. Changing the model means completed and remaining pages may use different models.')108        if st.button('Resume from saved progress',key='resume'+job.name):109            if shared_resume and (resume_model!='gemma4:12b-it-qat' or cfg.get('ocr_engine')!='yomitoku'):110                st.error('Select YomiToku and 12B for single-GPU parallel mode.')111            else:112                cfg['model']=resume_model113                cfg['mode']='shared' if shared_resume else ('auto' if cfg.get('mode')=='shared' else cfg.get('mode','auto'))114                save(job/'job.json',cfg);launch(job)115    ready=[i+1 for i in cfg['selected'] if (job/f'page-{i+1:05d}'/'marker.json').exists()]116    from frontier_export import render_button as render_frontier117    from fast_review import render as render_fast_review, adopt_pending118    with st.expander('Quick mode — model adoption and full-context handoff'):119        st.caption('Skip individual approval: adopt valid undecided model candidates, then prepare the complete handoff. Original OCR and previous page decisions are preserved. Failed or stale reviews remain in the handoff but are not adopted; deferred proposals outside a candidate are not inserted.')120        st.warning('Not manually verified. A downstream model cannot reliably recover missing text or correct every wrong number, name or negation.')121        if st.button('Adopt model candidates for Quick mode',key='bulk-adopt-'+job.name,disabled=running or not ready):122            count=adopt_pending(job,ready)123            st.session_state.pop('frontier-'+job.name+'-snapshot',None)124            st.success(f'Adopted {count} pages without manual verification. Original OCR is preserved.')125        render_frontier(st,job,'frontier-'+job.name,running)126    if ready:127        with st.expander('Fast review — pending changes and exceptions',expanded=False):128            render_fast_review(st,job,ready,running,accept)129        num=st.selectbox('Page to inspect',ready,key='page'+job.name)130        folder=job/f'page-{num:05d}'131        left,right=st.columns([1,1])132        with left:st.image(str(folder/'source.png'),caption=f'Source page {num}',use_container_width=True)133        with right:134            original=(folder/'original.md').read_text()135            t1,t2,t3=st.tabs(['Original OCR','Gemma suggestions','Changes'])136            with t1:st.text_area('Original text (saved)',original,height=430,disabled=True,key=f'original{job.name}{num}')137            with t2:138                if (folder/'review.json').exists():139                    review=read(folder/'review.json');candidate=(folder/'candidate.md').read_text()140                    st.caption(f"Review model for this page: {review.get('model','Unknown')}")141                    st.text_area('Suggested text from Gemma',candidate,height=330,disabled=True,key=f'candidate{job.name}{num}')142                    for correction in review['corrections']:st.write(correction)143                    for note in review['notes']:st.caption(note)144                    decision=read(folder/'decision.json') if (folder/'decision.json').exists() else {}145                    st.caption(('Quick mode: adopted without manual verification' if decision.get('method')=='bulk_model_adoption' else 'Approved') if decision.get('accepted') else 'Not approved')146                    if not running:147                        x,y=st.columns(2)148                        if x.button('Approve suggestions for this page',key=f'accept{job.name}{num}'):accept(job,num,True);st.rerun(scope='fragment')149                        if y.button('Restore original OCR',key=f'reject{job.name}{num}'):accept(job,num,False);st.rerun(scope='fragment')150                elif (folder/'review-error.json').exists():st.error(english_message(read(folder/'review-error.json')['error']))151                else:st.caption('Waiting for review, or Gemma review is disabled.')152            with t3:153                st.code((folder/'changes.diff').read_text() if (folder/'changes.diff').exists() else 'No changes to display yet',language='diff')154        if not running and (job/'original.md').exists():155            title,author=book_details(job,cfg)156            name_key=job.name+str((job/'job.json').stat().st_mtime_ns)157            with st.expander('Download filename settings'):158                title=st.text_input('Book title',value=title,key='booktitle'+name_key)159                author=st.text_input('Author',value=author,key='bookauthor'+name_key)160                override=st.text_input('Filename (optional)',value=cfg.get('download_name',''),key='downloadname'+name_key,help='Leave blank for Book title_Author.md. If the author is unknown, only the title is used.')161                st.caption('PDF title and author metadata are used when available. Otherwise, the input filename provides the title.')162                if st.button('Save title, author and filename',key='savename'+job.name):163                    cfg.update(title=title,author=author,download_name=override);save(job/'job.json',cfg)164                    from delivery import ensure_delivery165                    ensure_delivery(job)166                    st.rerun()167            filename=markdown_name(title,author,override)168            st.caption(f'Download filename: {filename}')169            x,y,z=st.columns(3)170            x.download_button('Original OCR MD',data=(job/'original.md').read_bytes(),file_name=filename,key='download1'+job.name)171            y.download_button('Reviewed MD',data=(job/'reviewed.md').read_bytes(),file_name=filename,key='download2'+job.name)172            z.download_button('Images, suggestions and records ZIP',data=bundle(job),file_name=job.name+'.zip',key='download3'+job.name)173            from delivery_ui import output_buttons174            output_buttons(job,'job-'+job.name)175            st.caption('The reviewed version uses suggestions only on approved pages; all other pages retain the original OCR.')176    with st.expander('Processing log'):177        st.code(english_message((job/'worker.log').read_text(errors='replace')[-6000:]) if (job/'worker.log').exists() else 'No log available yet')178show_job()179