Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -139,6 +139,10 @@ Primary goals for reconcile rate:
|
|
| 139 |
- Fix FH-2: Normalize packed DAILY BALANCE SUMMARY and CHECKS PAID SUMMARY tables into one-row-per-item
|
| 140 |
layouts; also promote multiline register headers where row0 is placeholder and row1 has true
|
| 141 |
DATE/DESCRIPTION/DEPOSIT/WITHDRAWAL labels.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
"""
|
| 143 |
|
| 144 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
@@ -164,16 +168,24 @@ from collections import Counter, defaultdict
|
|
| 164 |
from html.parser import HTMLParser
|
| 165 |
|
| 166 |
import yaml
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
log = logging.getLogger("glmocr_app")
|
| 171 |
logging.basicConfig(level=logging.INFO)
|
| 172 |
|
| 173 |
-
GLMOCR_BASE = os.path.dirname(glmocr.__file__)
|
| 174 |
-
CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
|
| 175 |
-
FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
|
| 176 |
-
|
| 177 |
# ============================================================
|
| 178 |
# HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
|
| 179 |
# ============================================================
|
|
@@ -214,33 +226,35 @@ def get_parser():
|
|
| 214 |
# ---------------------------------------------------------------------------
|
| 215 |
# Best-effort config tweaks (safe to fail on read-only HF env)
|
| 216 |
# ---------------------------------------------------------------------------
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
| 226 |
|
| 227 |
# Best-effort formatter tweak: avoid stripping header/footer labels
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
| 244 |
|
| 245 |
# --------------------------
|
| 246 |
# Header/footer helpers
|
|
@@ -3247,6 +3261,162 @@ def _expand_td_continuation_three_col_register_grid(grid):
|
|
| 3247 |
return new_grid
|
| 3248 |
|
| 3249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3250 |
def normalize_html_tables(text: str) -> str:
|
| 3251 |
if not text or "<table" not in text.lower():
|
| 3252 |
return text
|
|
@@ -3274,6 +3444,7 @@ def normalize_html_tables(text: str) -> str:
|
|
| 3274 |
|
| 3275 |
grid = _drop_truly_empty_columns(grid)
|
| 3276 |
grid = _merge_blank_header_text_columns(grid)
|
|
|
|
| 3277 |
_seq_linear = _split_wide_checks_sequenced_grid(grid)
|
| 3278 |
if _seq_linear is not None:
|
| 3279 |
grid = _seq_linear
|
|
@@ -5916,12 +6087,17 @@ def run_ocr(uploaded_file):
|
|
| 5916 |
except Exception:
|
| 5917 |
pass
|
| 5918 |
|
| 5919 |
-
|
| 5920 |
-
|
| 5921 |
-
|
| 5922 |
-
|
| 5923 |
-
|
| 5924 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5925 |
|
| 5926 |
if __name__ == "__main__":
|
| 5927 |
-
|
|
|
|
| 139 |
- Fix FH-2: Normalize packed DAILY BALANCE SUMMARY and CHECKS PAID SUMMARY tables into one-row-per-item
|
| 140 |
layouts; also promote multiline register headers where row0 is placeholder and row1 has true
|
| 141 |
DATE/DESCRIPTION/DEPOSIT/WITHDRAWAL labels.
|
| 142 |
+
- Fix SE-multi: Multi-account deposit summary tables (Beginning/Deposits/Withdrawals/Ending) sometimes get a
|
| 143 |
+
bogus OCR sub-row with tiny amounts (e.g. -$1 / $2 / $1) next to a real account row. Drop those
|
| 144 |
+
dust rows when another row has statement-scale balances, then recompute the TOTAL row from the
|
| 145 |
+
remaining account rows. Generic; no institution names.
|
| 146 |
"""
|
| 147 |
|
| 148 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
|
|
| 168 |
from html.parser import HTMLParser
|
| 169 |
|
| 170 |
import yaml
|
| 171 |
+
|
| 172 |
+
# Gradio and glmocr are optional at import: pro_processor only needs stabilize_tables_and_text
|
| 173 |
+
# (glm_ocr_client). HF Space / run_ocr still use glmocr + gradio when installed.
|
| 174 |
+
try:
|
| 175 |
+
import glmocr
|
| 176 |
+
|
| 177 |
+
GLMOCR_BASE = os.path.dirname(glmocr.__file__)
|
| 178 |
+
CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
|
| 179 |
+
FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
|
| 180 |
+
except ImportError:
|
| 181 |
+
glmocr = None # type: ignore
|
| 182 |
+
GLMOCR_BASE = ""
|
| 183 |
+
CONFIG_PATH = ""
|
| 184 |
+
FORMATTER_PATH = ""
|
| 185 |
|
| 186 |
log = logging.getLogger("glmocr_app")
|
| 187 |
logging.basicConfig(level=logging.INFO)
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
# ============================================================
|
| 190 |
# HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
|
| 191 |
# ============================================================
|
|
|
|
| 226 |
# ---------------------------------------------------------------------------
|
| 227 |
# Best-effort config tweaks (safe to fail on read-only HF env)
|
| 228 |
# ---------------------------------------------------------------------------
|
| 229 |
+
if CONFIG_PATH:
|
| 230 |
+
try:
|
| 231 |
+
with open(CONFIG_PATH, "r") as f:
|
| 232 |
+
config = yaml.safe_load(f)
|
| 233 |
+
config["pipeline"]["maas"]["enabled"] = True
|
| 234 |
+
config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY
|
| 235 |
+
with open(CONFIG_PATH, "w") as f:
|
| 236 |
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
| 237 |
+
except Exception:
|
| 238 |
+
pass
|
| 239 |
|
| 240 |
# Best-effort formatter tweak: avoid stripping header/footer labels
|
| 241 |
+
if FORMATTER_PATH:
|
| 242 |
+
try:
|
| 243 |
+
with open(FORMATTER_PATH, "r") as f:
|
| 244 |
+
source = f.read()
|
| 245 |
+
for label in (
|
| 246 |
+
'"header"', "'header'",
|
| 247 |
+
'"footer"', "'footer'",
|
| 248 |
+
'"doc_header"', "'doc_header'",
|
| 249 |
+
'"doc_footer"', "'doc_footer'",
|
| 250 |
+
):
|
| 251 |
+
source = re.sub(r",\s*" + re.escape(label), "", source)
|
| 252 |
+
source = re.sub(re.escape(label) + r"\s*,", "", source)
|
| 253 |
+
source = re.sub(re.escape(label), "", source)
|
| 254 |
+
with open(FORMATTER_PATH, "w") as f:
|
| 255 |
+
f.write(source)
|
| 256 |
+
except Exception:
|
| 257 |
+
pass
|
| 258 |
|
| 259 |
# --------------------------
|
| 260 |
# Header/footer helpers
|
|
|
|
| 3261 |
return new_grid
|
| 3262 |
|
| 3263 |
|
| 3264 |
+
def _parse_summary_table_money(cell: str) -> Optional[float]:
|
| 3265 |
+
"""Parse a currency cell from a statement summary row; None if not numeric."""
|
| 3266 |
+
if cell is None:
|
| 3267 |
+
return None
|
| 3268 |
+
t = str(cell).strip().replace(",", "").replace("$", "").replace(" ", "")
|
| 3269 |
+
if not t or t in ("-", "—", "–"):
|
| 3270 |
+
return None
|
| 3271 |
+
t = t.replace("(", "-").replace(")", "")
|
| 3272 |
+
try:
|
| 3273 |
+
return float(t)
|
| 3274 |
+
except ValueError:
|
| 3275 |
+
return None
|
| 3276 |
+
|
| 3277 |
+
|
| 3278 |
+
def _format_summary_table_money(value: float) -> str:
|
| 3279 |
+
sign = "-" if value < 0 else ""
|
| 3280 |
+
v = abs(value)
|
| 3281 |
+
s = f"{v:,.2f}"
|
| 3282 |
+
return sign + s
|
| 3283 |
+
|
| 3284 |
+
|
| 3285 |
+
def _drop_phantom_micro_account_rows_in_multi_account_summary(grid):
|
| 3286 |
+
"""
|
| 3287 |
+
Remove OCR phantom sub-account rows in credit-union / multi-account deposit summary tables
|
| 3288 |
+
(Acct | Beginning | Deposits | Withdrawals | Ending | …) when one row is dust and another
|
| 3289 |
+
is statement-scale; recompute TOTAL from remaining account rows.
|
| 3290 |
+
"""
|
| 3291 |
+
if not grid or len(grid) < 4:
|
| 3292 |
+
return grid
|
| 3293 |
+
|
| 3294 |
+
header = [str(c or "").strip() for c in grid[0]]
|
| 3295 |
+
ncols = len(header)
|
| 3296 |
+
if ncols < 5:
|
| 3297 |
+
return grid
|
| 3298 |
+
|
| 3299 |
+
header_join_u = " ".join(h.upper() for h in header)
|
| 3300 |
+
if "DEPOSIT" not in header_join_u or "WITHDRAW" not in header_join_u:
|
| 3301 |
+
return grid
|
| 3302 |
+
if "BEGINNING" not in header_join_u and "PREVIOUS" not in header_join_u:
|
| 3303 |
+
return grid
|
| 3304 |
+
has_ending = "ENDING" in header_join_u or (
|
| 3305 |
+
"NEW" in header_join_u and "BALANCE" in header_join_u
|
| 3306 |
+
)
|
| 3307 |
+
if not has_ending:
|
| 3308 |
+
return grid
|
| 3309 |
+
|
| 3310 |
+
def _col_match(pred):
|
| 3311 |
+
for i, h in enumerate(header):
|
| 3312 |
+
hu = h.upper()
|
| 3313 |
+
if pred(hu):
|
| 3314 |
+
return i
|
| 3315 |
+
return None
|
| 3316 |
+
|
| 3317 |
+
i_beg = _col_match(lambda u: "BEGINNING" in u or ("PREVIOUS" in u and "BALANCE" in u))
|
| 3318 |
+
i_dep = _col_match(lambda u: "DEPOSIT" in u and "WITHDRAW" not in u)
|
| 3319 |
+
i_wd = _col_match(lambda u: "WITHDRAW" in u)
|
| 3320 |
+
i_end = _col_match(
|
| 3321 |
+
lambda u: "ENDING" in u
|
| 3322 |
+
or ("NEW" in u and "BALANCE" in u)
|
| 3323 |
+
or u.startswith("CLOSING")
|
| 3324 |
+
)
|
| 3325 |
+
if None in (i_beg, i_dep, i_wd, i_end):
|
| 3326 |
+
return grid
|
| 3327 |
+
if len({i_beg, i_dep, i_wd, i_end}) != 4:
|
| 3328 |
+
return grid
|
| 3329 |
+
|
| 3330 |
+
money_idxs = (i_beg, i_dep, i_wd, i_end)
|
| 3331 |
+
|
| 3332 |
+
# Dust vs statement scale (tuned for OCR rows like -1 / 2 / 0 / 1 next to 18k withdrawals)
|
| 3333 |
+
_DUST_MAX = 10.0
|
| 3334 |
+
_DUST_SUM = 15.0
|
| 3335 |
+
_REAL_MIN = 3000.0
|
| 3336 |
+
|
| 3337 |
+
def _pad_row(row):
|
| 3338 |
+
r = [str(c or "").strip() for c in row]
|
| 3339 |
+
while len(r) < ncols:
|
| 3340 |
+
r.append("")
|
| 3341 |
+
return r[:ncols]
|
| 3342 |
+
|
| 3343 |
+
account_rows = [] # (grid_index, row_padded, max_abs, sum_abs, vals tuple)
|
| 3344 |
+
total_idx: Optional[int] = None
|
| 3345 |
+
|
| 3346 |
+
for ri in range(1, len(grid)):
|
| 3347 |
+
row = _pad_row(grid[ri])
|
| 3348 |
+
fc = row[0].strip().upper()
|
| 3349 |
+
if fc == "TOTAL" or fc.startswith("TOTAL "):
|
| 3350 |
+
total_idx = ri
|
| 3351 |
+
continue
|
| 3352 |
+
vals = tuple(
|
| 3353 |
+
_parse_summary_table_money(row[j]) if j < len(row) else None
|
| 3354 |
+
for j in money_idxs
|
| 3355 |
+
)
|
| 3356 |
+
if all(v is None for v in vals):
|
| 3357 |
+
continue
|
| 3358 |
+
abs_vals = [abs(v) for v in vals if v is not None]
|
| 3359 |
+
max_abs = max(abs_vals) if abs_vals else 0.0
|
| 3360 |
+
sum_abs = sum(abs(v) for v in vals if v is not None)
|
| 3361 |
+
account_rows.append((ri, row, max_abs, sum_abs, vals))
|
| 3362 |
+
|
| 3363 |
+
if len(account_rows) < 2:
|
| 3364 |
+
return grid
|
| 3365 |
+
|
| 3366 |
+
global_max = max(t[2] for t in account_rows)
|
| 3367 |
+
if global_max < _REAL_MIN:
|
| 3368 |
+
return grid
|
| 3369 |
+
|
| 3370 |
+
phantom_ri = set()
|
| 3371 |
+
for ri, row, max_abs, sum_abs, vals in account_rows:
|
| 3372 |
+
if max_abs <= _DUST_MAX and sum_abs <= _DUST_SUM:
|
| 3373 |
+
phantom_ri.add(ri)
|
| 3374 |
+
|
| 3375 |
+
if not phantom_ri:
|
| 3376 |
+
return grid
|
| 3377 |
+
|
| 3378 |
+
kept_accounts = [
|
| 3379 |
+
(ri, row, vals)
|
| 3380 |
+
for ri, row, max_abs, sum_abs, vals in account_rows
|
| 3381 |
+
if ri not in phantom_ri
|
| 3382 |
+
]
|
| 3383 |
+
if not kept_accounts:
|
| 3384 |
+
return grid
|
| 3385 |
+
|
| 3386 |
+
# Sum the four money columns across kept account rows
|
| 3387 |
+
sums = [0.0, 0.0, 0.0, 0.0]
|
| 3388 |
+
for _ri, _row, vals in kept_accounts:
|
| 3389 |
+
for k in range(4):
|
| 3390 |
+
if vals[k] is not None:
|
| 3391 |
+
sums[k] += vals[k]
|
| 3392 |
+
|
| 3393 |
+
new_grid: List[List[str]] = [list(header)]
|
| 3394 |
+
for ri in range(1, len(grid)):
|
| 3395 |
+
if ri in phantom_ri:
|
| 3396 |
+
continue
|
| 3397 |
+
if ri == total_idx:
|
| 3398 |
+
continue
|
| 3399 |
+
new_grid.append(_pad_row(grid[ri]))
|
| 3400 |
+
|
| 3401 |
+
if total_idx is not None:
|
| 3402 |
+
total_row = [""] * ncols
|
| 3403 |
+
total_row[0] = "TOTAL"
|
| 3404 |
+
for k, j in enumerate(money_idxs):
|
| 3405 |
+
total_row[j] = _format_summary_table_money(sums[k])
|
| 3406 |
+
old_total = _pad_row(grid[total_idx])
|
| 3407 |
+
for j in range(ncols):
|
| 3408 |
+
if j in (0,) + money_idxs:
|
| 3409 |
+
continue
|
| 3410 |
+
hj = header[j].upper() if j < len(header) else ""
|
| 3411 |
+
if "DIVIDEND" in hj or "YTD" in hj:
|
| 3412 |
+
total_row[j] = old_total[j] if old_total[j] else "0.00"
|
| 3413 |
+
elif old_total[j]:
|
| 3414 |
+
total_row[j] = old_total[j]
|
| 3415 |
+
new_grid.append(total_row)
|
| 3416 |
+
|
| 3417 |
+
return new_grid
|
| 3418 |
+
|
| 3419 |
+
|
| 3420 |
def normalize_html_tables(text: str) -> str:
|
| 3421 |
if not text or "<table" not in text.lower():
|
| 3422 |
return text
|
|
|
|
| 3444 |
|
| 3445 |
grid = _drop_truly_empty_columns(grid)
|
| 3446 |
grid = _merge_blank_header_text_columns(grid)
|
| 3447 |
+
grid = _drop_phantom_micro_account_rows_in_multi_account_summary(grid)
|
| 3448 |
_seq_linear = _split_wide_checks_sequenced_grid(grid)
|
| 3449 |
if _seq_linear is not None:
|
| 3450 |
grid = _seq_linear
|
|
|
|
| 6087 |
except Exception:
|
| 6088 |
pass
|
| 6089 |
|
| 6090 |
+
def _create_gradio_demo():
|
| 6091 |
+
import gradio as gr
|
| 6092 |
+
|
| 6093 |
+
with gr.Blocks(title="GLM-OCR") as demo:
|
| 6094 |
+
gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.")
|
| 6095 |
+
file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"])
|
| 6096 |
+
run_btn = gr.Button("Run OCR", variant="primary")
|
| 6097 |
+
out = gr.Textbox(lines=40, label="Output (markdown)")
|
| 6098 |
+
run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
|
| 6099 |
+
return demo
|
| 6100 |
+
|
| 6101 |
|
| 6102 |
if __name__ == "__main__":
|
| 6103 |
+
_create_gradio_demo().launch()
|