Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +85 -82
src/streamlit_app.py
CHANGED
|
@@ -7,9 +7,9 @@ from PIL import Image, ImageEnhance
|
|
| 7 |
from streamlit_drawable_canvas import st_canvas
|
| 8 |
import pytesseract
|
| 9 |
|
| 10 |
-
#
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
st.set_page_config(
|
| 14 |
page_title="Remittance GT Annotator - Interactive OCR",
|
| 15 |
layout="wide"
|
|
@@ -17,7 +17,9 @@ st.set_page_config(
|
|
| 17 |
|
| 18 |
st.title("Remittance GT Annotator - Interactive OCR")
|
| 19 |
|
| 20 |
-
#
|
|
|
|
|
|
|
| 21 |
SINGLE_FIELDS = [
|
| 22 |
"Remittance Advice Number",
|
| 23 |
"Remittance Advice Date",
|
|
@@ -63,7 +65,9 @@ COLOR_PALETTE = [
|
|
| 63 |
ALL_BASE_FIELDS = SINGLE_FIELDS + LINE_ITEM_FIELDS
|
| 64 |
FIELD_COLORS = {field: COLOR_PALETTE[i % len(COLOR_PALETTE)] for i, field in enumerate(ALL_BASE_FIELDS)}
|
| 65 |
|
| 66 |
-
#
|
|
|
|
|
|
|
| 67 |
HEADER_GROUPS = {
|
| 68 |
"remittance_advice_details": {
|
| 69 |
"Remittance Advice Number": "remittance_advice_number",
|
|
@@ -106,70 +110,64 @@ LINE_ITEM_FIELD_KEY_MAP = {
|
|
| 106 |
# Fixed zoom options
|
| 107 |
ZOOM_OPTIONS = [25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100, 110, 120, 130, 140, 150]
|
| 108 |
|
| 109 |
-
#
|
|
|
|
|
|
|
| 110 |
if "field_values" not in st.session_state:
|
| 111 |
-
st.session_state.field_values = {}
|
| 112 |
if "field_rects_orig" not in st.session_state:
|
| 113 |
-
st.session_state.field_rects_orig = {}
|
| 114 |
if "num_line_items" not in st.session_state:
|
| 115 |
-
st.session_state.num_line_items = {}
|
| 116 |
if "selected_image" not in st.session_state:
|
| 117 |
st.session_state.selected_image = None
|
| 118 |
if "zoom_values" not in st.session_state:
|
| 119 |
-
st.session_state.zoom_values = {}
|
| 120 |
if "rect_version" not in st.session_state:
|
| 121 |
-
st.session_state.rect_version = {}
|
| 122 |
if "image_data" not in st.session_state:
|
| 123 |
-
st.session_state.image_data = {}
|
| 124 |
-
|
| 125 |
-
# Pending delete - process at start before UI
|
| 126 |
if "pending_delete" not in st.session_state:
|
| 127 |
st.session_state.pending_delete = None
|
| 128 |
|
|
|
|
| 129 |
if st.session_state.pending_delete is not None:
|
| 130 |
img_name, field_key = st.session_state.pending_delete
|
| 131 |
if img_name in st.session_state.field_rects_orig:
|
| 132 |
st.session_state.field_rects_orig[img_name].pop(field_key, None)
|
|
|
|
|
|
|
| 133 |
if img_name in st.session_state.rect_version:
|
| 134 |
st.session_state.rect_version[img_name] += 1
|
| 135 |
st.session_state.pending_delete = None
|
|
|
|
|
|
|
| 136 |
|
| 137 |
-
#
|
|
|
|
|
|
|
| 138 |
@st.cache_data
|
| 139 |
-
def load_image(file_content):
|
| 140 |
return Image.open(BytesIO(file_content)).convert("RGB")
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
|
|
|
| 144 |
pil_image = Image.open(BytesIO(image_bytes)).convert("RGB")
|
| 145 |
resized = pil_image.resize((width, height), Image.LANCZOS)
|
| 146 |
resized = ImageEnhance.Sharpness(resized).enhance(1.2)
|
| 147 |
resized = ImageEnhance.Contrast(resized).enhance(1.1)
|
| 148 |
return resized
|
| 149 |
|
| 150 |
-
def get_default_zoom(pil_image):
|
| 151 |
-
"""Calculate best fit zoom"""
|
| 152 |
MAX_WIDTH = 850
|
| 153 |
MAX_HEIGHT = 900
|
| 154 |
default_scale = min(MAX_WIDTH / pil_image.width, MAX_HEIGHT / pil_image.height, 1.0)
|
| 155 |
default_zoom = int(default_scale * 100)
|
| 156 |
-
# Find closest zoom option
|
| 157 |
closest = min(ZOOM_OPTIONS, key=lambda x: abs(x - default_zoom))
|
| 158 |
return closest
|
| 159 |
|
| 160 |
def build_gt_record_for_file(file_name: str) -> dict:
|
| 161 |
-
"""
|
| 162 |
-
JSONL record for one remittance image:
|
| 163 |
-
{
|
| 164 |
-
"file_name": "<image>",
|
| 165 |
-
"gt_parse": {
|
| 166 |
-
"remittance_advice_details": {...},
|
| 167 |
-
"customer_supplier_details": {...},
|
| 168 |
-
"bank_details": {...},
|
| 169 |
-
"line_items": [...]
|
| 170 |
-
}
|
| 171 |
-
}
|
| 172 |
-
"""
|
| 173 |
values = st.session_state.field_values.get(file_name, {})
|
| 174 |
num_items = st.session_state.num_line_items.get(file_name, 1)
|
| 175 |
|
|
@@ -207,16 +205,17 @@ def build_gt_record_for_file(file_name: str) -> dict:
|
|
| 207 |
}
|
| 208 |
|
| 209 |
def has_any_label(fname: str) -> bool:
|
| 210 |
-
"""Check if file has any labeled values"""
|
| 211 |
vals = st.session_state.field_values.get(fname, {})
|
| 212 |
return any(str(v).strip() for v in vals.values())
|
| 213 |
|
| 214 |
-
#
|
|
|
|
|
|
|
| 215 |
uploaded_files = st.file_uploader(
|
| 216 |
"Upload remittance images",
|
| 217 |
type=["png", "jpg", "jpeg"],
|
| 218 |
accept_multiple_files=True,
|
| 219 |
-
label_visibility="collapsed"
|
| 220 |
)
|
| 221 |
|
| 222 |
if not uploaded_files:
|
|
@@ -227,7 +226,6 @@ images = []
|
|
| 227 |
for f in uploaded_files:
|
| 228 |
f.seek(0)
|
| 229 |
content = f.read()
|
| 230 |
-
# Store image bytes in session state for stability across reruns
|
| 231 |
if f.name not in st.session_state.image_data:
|
| 232 |
st.session_state.image_data[f.name] = content
|
| 233 |
img = load_image(st.session_state.image_data[f.name])
|
|
@@ -235,7 +233,6 @@ for f in uploaded_files:
|
|
| 235 |
|
| 236 |
file_names = [img["name"] for img in images]
|
| 237 |
|
| 238 |
-
# Image selector dropdown only (no duplicate list above)
|
| 239 |
selected_name = st.selectbox("Select image", file_names, label_visibility="collapsed")
|
| 240 |
st.session_state.selected_image = selected_name
|
| 241 |
|
|
@@ -243,31 +240,35 @@ selected_img_data = next(img for img in images if img["name"] == selected_name)
|
|
| 243 |
pil_image = selected_img_data["image"]
|
| 244 |
image_bytes = selected_img_data["bytes"]
|
| 245 |
|
| 246 |
-
# Init
|
| 247 |
if selected_name not in st.session_state.field_values:
|
| 248 |
st.session_state.field_values[selected_name] = {}
|
| 249 |
if selected_name not in st.session_state.field_rects_orig:
|
| 250 |
st.session_state.field_rects_orig[selected_name] = {}
|
| 251 |
if selected_name not in st.session_state.num_line_items:
|
| 252 |
st.session_state.num_line_items[selected_name] = 1
|
| 253 |
-
if selected_name not in st.session_state.rect_version:
|
| 254 |
-
st.session_state.rect_version[selected_name] = 0
|
| 255 |
if selected_name not in st.session_state.zoom_values:
|
| 256 |
st.session_state.zoom_values[selected_name] = get_default_zoom(pil_image)
|
|
|
|
|
|
|
| 257 |
|
| 258 |
-
#
|
|
|
|
|
|
|
| 259 |
col1, col2 = st.columns([3, 2])
|
| 260 |
|
| 261 |
-
#
|
| 262 |
display_field_name = SINGLE_FIELDS[0]
|
| 263 |
storage_field_name = SINGLE_FIELDS[0]
|
| 264 |
base_field_for_color = SINGLE_FIELDS[0]
|
| 265 |
field_color = FIELD_COLORS[base_field_for_color]
|
| 266 |
|
| 267 |
-
#
|
|
|
|
|
|
|
| 268 |
with col2:
|
| 269 |
st.markdown("#### 🎯 Field Selection")
|
| 270 |
-
|
| 271 |
def add_line_item():
|
| 272 |
img = st.session_state.selected_image
|
| 273 |
if img:
|
|
@@ -283,6 +284,7 @@ with col2:
|
|
| 283 |
st.session_state.field_rects_orig[img].pop(key, None)
|
| 284 |
st.session_state.num_line_items[img] -= 1
|
| 285 |
st.session_state.rect_version[img] += 1
|
|
|
|
| 286 |
|
| 287 |
field_type = st.radio("Type", ["Single", "Line Item"], horizontal=True, label_visibility="collapsed")
|
| 288 |
|
|
@@ -293,19 +295,19 @@ with col2:
|
|
| 293 |
base_field_for_color = field_name
|
| 294 |
else:
|
| 295 |
num_items = st.session_state.num_line_items[selected_name]
|
| 296 |
-
|
| 297 |
line_col1, add_col, rem_col = st.columns([2, 1, 1])
|
| 298 |
with line_col1:
|
| 299 |
line_item_options = [f"Line {i+1}" for i in range(num_items)]
|
| 300 |
selected_line_item = st.selectbox("Line", line_item_options, label_visibility="collapsed")
|
| 301 |
line_item_num = int(selected_line_item.split()[1])
|
| 302 |
-
|
| 303 |
with add_col:
|
| 304 |
st.button("➕", key=f"addli_{selected_name}", on_click=add_line_item, help="Add line item")
|
| 305 |
with rem_col:
|
| 306 |
if st.session_state.num_line_items[selected_name] > 1:
|
| 307 |
st.button("➖", key=f"remli_{selected_name}", on_click=remove_line_item, help="Remove line item")
|
| 308 |
-
|
| 309 |
base_field = st.selectbox("Field", LINE_ITEM_FIELDS, label_visibility="collapsed")
|
| 310 |
display_field_name = f"{selected_line_item}: {base_field}"
|
| 311 |
storage_field_name = f"Line {line_item_num}: {base_field}"
|
|
@@ -321,33 +323,33 @@ with col2:
|
|
| 321 |
)
|
| 322 |
|
| 323 |
st.markdown("#### 🔍 Zoom")
|
| 324 |
-
|
| 325 |
current_zoom = st.session_state.zoom_values[selected_name]
|
| 326 |
zoom_index = ZOOM_OPTIONS.index(current_zoom) if current_zoom in ZOOM_OPTIONS else 0
|
| 327 |
-
|
| 328 |
def do_zoom_out():
|
| 329 |
img = st.session_state.selected_image
|
| 330 |
curr = st.session_state.zoom_values[img]
|
| 331 |
idx = ZOOM_OPTIONS.index(curr) if curr in ZOOM_OPTIONS else 0
|
| 332 |
if idx > 0:
|
| 333 |
st.session_state.zoom_values[img] = ZOOM_OPTIONS[idx - 1]
|
| 334 |
-
|
| 335 |
def do_zoom_in():
|
| 336 |
img = st.session_state.selected_image
|
| 337 |
curr = st.session_state.zoom_values[img]
|
| 338 |
idx = ZOOM_OPTIONS.index(curr) if curr in ZOOM_OPTIONS else 0
|
| 339 |
if idx < len(ZOOM_OPTIONS) - 1:
|
| 340 |
st.session_state.zoom_values[img] = ZOOM_OPTIONS[idx + 1]
|
| 341 |
-
|
| 342 |
def do_zoom_fit():
|
| 343 |
img = st.session_state.selected_image
|
| 344 |
img_bytes = st.session_state.image_data.get(img)
|
| 345 |
if img_bytes:
|
| 346 |
pil_img = load_image(img_bytes)
|
| 347 |
st.session_state.zoom_values[img] = get_default_zoom(pil_img)
|
| 348 |
-
|
| 349 |
zoom_col1, zoom_col2, zoom_col3, zoom_col4 = st.columns([2, 1, 1, 1])
|
| 350 |
-
|
| 351 |
with zoom_col1:
|
| 352 |
zoom = st.selectbox(
|
| 353 |
"Zoom",
|
|
@@ -355,29 +357,31 @@ with col2:
|
|
| 355 |
index=zoom_index,
|
| 356 |
format_func=lambda x: f"{x}%",
|
| 357 |
key=f"zoom_select_{selected_name}",
|
| 358 |
-
label_visibility="collapsed"
|
| 359 |
)
|
| 360 |
st.session_state.zoom_values[selected_name] = zoom
|
| 361 |
-
|
| 362 |
with zoom_col2:
|
| 363 |
-
st.button("➖", key="
|
| 364 |
-
|
| 365 |
with zoom_col3:
|
| 366 |
-
st.button("➕", key="
|
| 367 |
-
|
| 368 |
with zoom_col4:
|
| 369 |
-
st.button("Fit", key="
|
| 370 |
|
| 371 |
st.caption(f"Original: {pil_image.width}×{pil_image.height}")
|
| 372 |
|
| 373 |
-
#
|
|
|
|
|
|
|
| 374 |
with col1:
|
| 375 |
zoom = st.session_state.zoom_values[selected_name]
|
| 376 |
scale = zoom / 100.0
|
| 377 |
disp_w = int(pil_image.width * scale)
|
| 378 |
disp_h = int(pil_image.height * scale)
|
| 379 |
-
|
| 380 |
-
display_image =
|
| 381 |
|
| 382 |
def orig_to_display(rect_orig, s):
|
| 383 |
return {
|
|
@@ -405,9 +409,10 @@ with col1:
|
|
| 405 |
"strokeWidth": rect_display.get("strokeWidth", 2),
|
| 406 |
}
|
| 407 |
|
| 408 |
-
# Build
|
| 409 |
all_display_objects = []
|
| 410 |
-
|
|
|
|
| 411 |
disp_rect = orig_to_display(rect_orig, scale)
|
| 412 |
base = fld.split(": ", 1)[1] if ": " in fld else fld
|
| 413 |
disp_rect["stroke"] = FIELD_COLORS.get(base, "#FF0000")
|
|
@@ -418,7 +423,7 @@ with col1:
|
|
| 418 |
expected_count = len(all_display_objects)
|
| 419 |
|
| 420 |
rect_ver = st.session_state.rect_version[selected_name]
|
| 421 |
-
num_rects = len(
|
| 422 |
canvas_key = f"canvas_{selected_name}_z{zoom}_rv{rect_ver}_n{num_rects}"
|
| 423 |
|
| 424 |
canvas_result = st_canvas(
|
|
@@ -434,18 +439,19 @@ with col1:
|
|
| 434 |
key=canvas_key,
|
| 435 |
)
|
| 436 |
|
| 437 |
-
# Detect new rectangle
|
| 438 |
if canvas_result.json_data is not None:
|
| 439 |
-
objs = canvas_result.json_data.get("objects", [])
|
| 440 |
if len(objs) > expected_count:
|
| 441 |
new_rect_display = objs[-1]
|
| 442 |
new_rect_orig = display_to_orig(new_rect_display, scale)
|
| 443 |
new_rect_orig["stroke"] = field_color
|
| 444 |
|
| 445 |
-
#
|
| 446 |
st.session_state.field_rects_orig[selected_name][storage_field_name] = new_rect_orig
|
| 447 |
st.session_state.rect_version[selected_name] += 1
|
| 448 |
|
|
|
|
| 449 |
x1 = max(0, int(new_rect_orig["left"]))
|
| 450 |
y1 = max(0, int(new_rect_orig["top"]))
|
| 451 |
x2 = min(pil_image.width, int(new_rect_orig["left"] + new_rect_orig["width"]))
|
|
@@ -456,7 +462,6 @@ with col1:
|
|
| 456 |
try:
|
| 457 |
text = pytesseract.image_to_string(crop, config="--psm 6").strip()
|
| 458 |
if text:
|
| 459 |
-
# update field values and the text-area state key
|
| 460 |
st.session_state.field_values[selected_name][storage_field_name] = text
|
| 461 |
value_state_key = f"value_{selected_name}_{storage_field_name}"
|
| 462 |
st.session_state[value_state_key] = text
|
|
@@ -468,14 +473,17 @@ with col1:
|
|
| 468 |
else:
|
| 469 |
st.toast("✅ Rectangle saved")
|
| 470 |
|
| 471 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
with col2:
|
| 473 |
st.markdown("#### ✏️ OCR & Value")
|
| 474 |
|
| 475 |
current_rect_orig = st.session_state.field_rects_orig[selected_name].get(storage_field_name)
|
| 476 |
value_state_key = f"value_{selected_name}_{storage_field_name}"
|
| 477 |
-
|
| 478 |
-
# initialise text state from saved field value on first use
|
| 479 |
if value_state_key not in st.session_state:
|
| 480 |
st.session_state[value_state_key] = st.session_state.field_values[selected_name].get(
|
| 481 |
storage_field_name, ""
|
|
@@ -513,7 +521,6 @@ with col2:
|
|
| 513 |
if current_rect_orig:
|
| 514 |
st.button("🗑️ Delete", on_click=delete_rect)
|
| 515 |
|
| 516 |
-
# Text area bound to state key (so it reflects auto-OCR & Re-OCR without reruns)
|
| 517 |
st.text_area(
|
| 518 |
"Value (auto-filled by OCR)",
|
| 519 |
key=value_state_key,
|
|
@@ -522,7 +529,7 @@ with col2:
|
|
| 522 |
placeholder="Value (auto-filled by OCR)",
|
| 523 |
)
|
| 524 |
|
| 525 |
-
#
|
| 526 |
with st.expander("📋 All Values"):
|
| 527 |
for f in SINGLE_FIELDS:
|
| 528 |
v = st.session_state.field_values[selected_name].get(f, "")
|
|
@@ -540,10 +547,9 @@ with col2:
|
|
| 540 |
for lif, v in vals:
|
| 541 |
st.write(f" {lif}: {v}")
|
| 542 |
|
| 543 |
-
#
|
| 544 |
st.markdown("#### 📤 JSONL Export")
|
| 545 |
|
| 546 |
-
# Export ALL labeled remittances
|
| 547 |
records_all = [
|
| 548 |
build_gt_record_for_file(img["name"])
|
| 549 |
for img in images
|
|
@@ -551,9 +557,7 @@ with col2:
|
|
| 551 |
]
|
| 552 |
|
| 553 |
if records_all:
|
| 554 |
-
all_jsonl_str = "\n".join(
|
| 555 |
-
json.dumps(rec, ensure_ascii=False) for rec in records_all
|
| 556 |
-
)
|
| 557 |
st.download_button(
|
| 558 |
"⬇️ Export ALL labeled (JSONL)",
|
| 559 |
data=all_jsonl_str.encode("utf-8"),
|
|
@@ -563,7 +567,6 @@ with col2:
|
|
| 563 |
else:
|
| 564 |
st.caption("No labeled remittances yet.")
|
| 565 |
|
| 566 |
-
# Export CURRENT remittance
|
| 567 |
current_record = build_gt_record_for_file(selected_name)
|
| 568 |
with st.expander("Preview CURRENT JSON"):
|
| 569 |
st.json(current_record)
|
|
|
|
| 7 |
from streamlit_drawable_canvas import st_canvas
|
| 8 |
import pytesseract
|
| 9 |
|
| 10 |
+
# ---------------------------------
|
| 11 |
+
# Page config
|
| 12 |
+
# ---------------------------------
|
| 13 |
st.set_page_config(
|
| 14 |
page_title="Remittance GT Annotator - Interactive OCR",
|
| 15 |
layout="wide"
|
|
|
|
| 17 |
|
| 18 |
st.title("Remittance GT Annotator - Interactive OCR")
|
| 19 |
|
| 20 |
+
# ---------------------------------
|
| 21 |
+
# Field definitions
|
| 22 |
+
# ---------------------------------
|
| 23 |
SINGLE_FIELDS = [
|
| 24 |
"Remittance Advice Number",
|
| 25 |
"Remittance Advice Date",
|
|
|
|
| 65 |
ALL_BASE_FIELDS = SINGLE_FIELDS + LINE_ITEM_FIELDS
|
| 66 |
FIELD_COLORS = {field: COLOR_PALETTE[i % len(COLOR_PALETTE)] for i, field in enumerate(ALL_BASE_FIELDS)}
|
| 67 |
|
| 68 |
+
# ---------------------------------
|
| 69 |
+
# JSONL schema mappings
|
| 70 |
+
# ---------------------------------
|
| 71 |
HEADER_GROUPS = {
|
| 72 |
"remittance_advice_details": {
|
| 73 |
"Remittance Advice Number": "remittance_advice_number",
|
|
|
|
| 110 |
# Fixed zoom options
|
| 111 |
ZOOM_OPTIONS = [25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100, 110, 120, 130, 140, 150]
|
| 112 |
|
| 113 |
+
# ---------------------------------
|
| 114 |
+
# Session state init
|
| 115 |
+
# ---------------------------------
|
| 116 |
if "field_values" not in st.session_state:
|
| 117 |
+
st.session_state.field_values = {} # {image_name: {field_name: value}}
|
| 118 |
if "field_rects_orig" not in st.session_state:
|
| 119 |
+
st.session_state.field_rects_orig = {} # {image_name: {field_name: rect_in_orig_coords}}
|
| 120 |
if "num_line_items" not in st.session_state:
|
| 121 |
+
st.session_state.num_line_items = {} # {image_name: int}
|
| 122 |
if "selected_image" not in st.session_state:
|
| 123 |
st.session_state.selected_image = None
|
| 124 |
if "zoom_values" not in st.session_state:
|
| 125 |
+
st.session_state.zoom_values = {} # {image_name: zoom_int}
|
| 126 |
if "rect_version" not in st.session_state:
|
| 127 |
+
st.session_state.rect_version = {} # {image_name: int}
|
| 128 |
if "image_data" not in st.session_state:
|
| 129 |
+
st.session_state.image_data = {} # {image_name: bytes}
|
|
|
|
|
|
|
| 130 |
if "pending_delete" not in st.session_state:
|
| 131 |
st.session_state.pending_delete = None
|
| 132 |
|
| 133 |
+
# Process pending delete early
|
| 134 |
if st.session_state.pending_delete is not None:
|
| 135 |
img_name, field_key = st.session_state.pending_delete
|
| 136 |
if img_name in st.session_state.field_rects_orig:
|
| 137 |
st.session_state.field_rects_orig[img_name].pop(field_key, None)
|
| 138 |
+
if img_name in st.session_state.field_values:
|
| 139 |
+
st.session_state.field_values[img_name].pop(field_key, None)
|
| 140 |
if img_name in st.session_state.rect_version:
|
| 141 |
st.session_state.rect_version[img_name] += 1
|
| 142 |
st.session_state.pending_delete = None
|
| 143 |
+
# Force a quick rerun so canvas reflects deletion
|
| 144 |
+
st.experimental_rerun()
|
| 145 |
|
| 146 |
+
# ---------------------------------
|
| 147 |
+
# Helper functions
|
| 148 |
+
# ---------------------------------
|
| 149 |
@st.cache_data
|
| 150 |
+
def load_image(file_content: bytes):
|
| 151 |
return Image.open(BytesIO(file_content)).convert("RGB")
|
| 152 |
|
| 153 |
+
@st.cache_data
|
| 154 |
+
def get_display_image(image_bytes: bytes, width: int, height: int):
|
| 155 |
+
"""Cached resize + enhancement to minimize flicker on reruns."""
|
| 156 |
pil_image = Image.open(BytesIO(image_bytes)).convert("RGB")
|
| 157 |
resized = pil_image.resize((width, height), Image.LANCZOS)
|
| 158 |
resized = ImageEnhance.Sharpness(resized).enhance(1.2)
|
| 159 |
resized = ImageEnhance.Contrast(resized).enhance(1.1)
|
| 160 |
return resized
|
| 161 |
|
| 162 |
+
def get_default_zoom(pil_image: Image.Image) -> int:
|
|
|
|
| 163 |
MAX_WIDTH = 850
|
| 164 |
MAX_HEIGHT = 900
|
| 165 |
default_scale = min(MAX_WIDTH / pil_image.width, MAX_HEIGHT / pil_image.height, 1.0)
|
| 166 |
default_zoom = int(default_scale * 100)
|
|
|
|
| 167 |
closest = min(ZOOM_OPTIONS, key=lambda x: abs(x - default_zoom))
|
| 168 |
return closest
|
| 169 |
|
| 170 |
def build_gt_record_for_file(file_name: str) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
values = st.session_state.field_values.get(file_name, {})
|
| 172 |
num_items = st.session_state.num_line_items.get(file_name, 1)
|
| 173 |
|
|
|
|
| 205 |
}
|
| 206 |
|
| 207 |
def has_any_label(fname: str) -> bool:
|
|
|
|
| 208 |
vals = st.session_state.field_values.get(fname, {})
|
| 209 |
return any(str(v).strip() for v in vals.values())
|
| 210 |
|
| 211 |
+
# ---------------------------------
|
| 212 |
+
# Upload
|
| 213 |
+
# ---------------------------------
|
| 214 |
uploaded_files = st.file_uploader(
|
| 215 |
"Upload remittance images",
|
| 216 |
type=["png", "jpg", "jpeg"],
|
| 217 |
accept_multiple_files=True,
|
| 218 |
+
label_visibility="collapsed",
|
| 219 |
)
|
| 220 |
|
| 221 |
if not uploaded_files:
|
|
|
|
| 226 |
for f in uploaded_files:
|
| 227 |
f.seek(0)
|
| 228 |
content = f.read()
|
|
|
|
| 229 |
if f.name not in st.session_state.image_data:
|
| 230 |
st.session_state.image_data[f.name] = content
|
| 231 |
img = load_image(st.session_state.image_data[f.name])
|
|
|
|
| 233 |
|
| 234 |
file_names = [img["name"] for img in images]
|
| 235 |
|
|
|
|
| 236 |
selected_name = st.selectbox("Select image", file_names, label_visibility="collapsed")
|
| 237 |
st.session_state.selected_image = selected_name
|
| 238 |
|
|
|
|
| 240 |
pil_image = selected_img_data["image"]
|
| 241 |
image_bytes = selected_img_data["bytes"]
|
| 242 |
|
| 243 |
+
# Init per-image state
|
| 244 |
if selected_name not in st.session_state.field_values:
|
| 245 |
st.session_state.field_values[selected_name] = {}
|
| 246 |
if selected_name not in st.session_state.field_rects_orig:
|
| 247 |
st.session_state.field_rects_orig[selected_name] = {}
|
| 248 |
if selected_name not in st.session_state.num_line_items:
|
| 249 |
st.session_state.num_line_items[selected_name] = 1
|
|
|
|
|
|
|
| 250 |
if selected_name not in st.session_state.zoom_values:
|
| 251 |
st.session_state.zoom_values[selected_name] = get_default_zoom(pil_image)
|
| 252 |
+
if selected_name not in st.session_state.rect_version:
|
| 253 |
+
st.session_state.rect_version[selected_name] = 0
|
| 254 |
|
| 255 |
+
# ---------------------------------
|
| 256 |
+
# Layout columns
|
| 257 |
+
# ---------------------------------
|
| 258 |
col1, col2 = st.columns([3, 2])
|
| 259 |
|
| 260 |
+
# Defaults for current field
|
| 261 |
display_field_name = SINGLE_FIELDS[0]
|
| 262 |
storage_field_name = SINGLE_FIELDS[0]
|
| 263 |
base_field_for_color = SINGLE_FIELDS[0]
|
| 264 |
field_color = FIELD_COLORS[base_field_for_color]
|
| 265 |
|
| 266 |
+
# ---------------------------------
|
| 267 |
+
# RHS TOP: Field selection + zoom
|
| 268 |
+
# ---------------------------------
|
| 269 |
with col2:
|
| 270 |
st.markdown("#### 🎯 Field Selection")
|
| 271 |
+
|
| 272 |
def add_line_item():
|
| 273 |
img = st.session_state.selected_image
|
| 274 |
if img:
|
|
|
|
| 284 |
st.session_state.field_rects_orig[img].pop(key, None)
|
| 285 |
st.session_state.num_line_items[img] -= 1
|
| 286 |
st.session_state.rect_version[img] += 1
|
| 287 |
+
st.experimental_rerun()
|
| 288 |
|
| 289 |
field_type = st.radio("Type", ["Single", "Line Item"], horizontal=True, label_visibility="collapsed")
|
| 290 |
|
|
|
|
| 295 |
base_field_for_color = field_name
|
| 296 |
else:
|
| 297 |
num_items = st.session_state.num_line_items[selected_name]
|
| 298 |
+
|
| 299 |
line_col1, add_col, rem_col = st.columns([2, 1, 1])
|
| 300 |
with line_col1:
|
| 301 |
line_item_options = [f"Line {i+1}" for i in range(num_items)]
|
| 302 |
selected_line_item = st.selectbox("Line", line_item_options, label_visibility="collapsed")
|
| 303 |
line_item_num = int(selected_line_item.split()[1])
|
| 304 |
+
|
| 305 |
with add_col:
|
| 306 |
st.button("➕", key=f"addli_{selected_name}", on_click=add_line_item, help="Add line item")
|
| 307 |
with rem_col:
|
| 308 |
if st.session_state.num_line_items[selected_name] > 1:
|
| 309 |
st.button("➖", key=f"remli_{selected_name}", on_click=remove_line_item, help="Remove line item")
|
| 310 |
+
|
| 311 |
base_field = st.selectbox("Field", LINE_ITEM_FIELDS, label_visibility="collapsed")
|
| 312 |
display_field_name = f"{selected_line_item}: {base_field}"
|
| 313 |
storage_field_name = f"Line {line_item_num}: {base_field}"
|
|
|
|
| 323 |
)
|
| 324 |
|
| 325 |
st.markdown("#### 🔍 Zoom")
|
| 326 |
+
|
| 327 |
current_zoom = st.session_state.zoom_values[selected_name]
|
| 328 |
zoom_index = ZOOM_OPTIONS.index(current_zoom) if current_zoom in ZOOM_OPTIONS else 0
|
| 329 |
+
|
| 330 |
def do_zoom_out():
|
| 331 |
img = st.session_state.selected_image
|
| 332 |
curr = st.session_state.zoom_values[img]
|
| 333 |
idx = ZOOM_OPTIONS.index(curr) if curr in ZOOM_OPTIONS else 0
|
| 334 |
if idx > 0:
|
| 335 |
st.session_state.zoom_values[img] = ZOOM_OPTIONS[idx - 1]
|
| 336 |
+
|
| 337 |
def do_zoom_in():
|
| 338 |
img = st.session_state.selected_image
|
| 339 |
curr = st.session_state.zoom_values[img]
|
| 340 |
idx = ZOOM_OPTIONS.index(curr) if curr in ZOOM_OPTIONS else 0
|
| 341 |
if idx < len(ZOOM_OPTIONS) - 1:
|
| 342 |
st.session_state.zoom_values[img] = ZOOM_OPTIONS[idx + 1]
|
| 343 |
+
|
| 344 |
def do_zoom_fit():
|
| 345 |
img = st.session_state.selected_image
|
| 346 |
img_bytes = st.session_state.image_data.get(img)
|
| 347 |
if img_bytes:
|
| 348 |
pil_img = load_image(img_bytes)
|
| 349 |
st.session_state.zoom_values[img] = get_default_zoom(pil_img)
|
| 350 |
+
|
| 351 |
zoom_col1, zoom_col2, zoom_col3, zoom_col4 = st.columns([2, 1, 1, 1])
|
| 352 |
+
|
| 353 |
with zoom_col1:
|
| 354 |
zoom = st.selectbox(
|
| 355 |
"Zoom",
|
|
|
|
| 357 |
index=zoom_index,
|
| 358 |
format_func=lambda x: f"{x}%",
|
| 359 |
key=f"zoom_select_{selected_name}",
|
| 360 |
+
label_visibility="collapsed",
|
| 361 |
)
|
| 362 |
st.session_state.zoom_values[selected_name] = zoom
|
| 363 |
+
|
| 364 |
with zoom_col2:
|
| 365 |
+
st.button("➖", key=f"zoom_out_{selected_name}", help="Zoom out", on_click=do_zoom_out)
|
| 366 |
+
|
| 367 |
with zoom_col3:
|
| 368 |
+
st.button("➕", key=f"zoom_in_{selected_name}", help="Zoom in", on_click=do_zoom_in)
|
| 369 |
+
|
| 370 |
with zoom_col4:
|
| 371 |
+
st.button("Fit", key=f"zoom_fit_{selected_name}", help="Fit to screen", on_click=do_zoom_fit)
|
| 372 |
|
| 373 |
st.caption(f"Original: {pil_image.width}×{pil_image.height}")
|
| 374 |
|
| 375 |
+
# ---------------------------------
|
| 376 |
+
# LHS: Canvas / Image
|
| 377 |
+
# ---------------------------------
|
| 378 |
with col1:
|
| 379 |
zoom = st.session_state.zoom_values[selected_name]
|
| 380 |
scale = zoom / 100.0
|
| 381 |
disp_w = int(pil_image.width * scale)
|
| 382 |
disp_h = int(pil_image.height * scale)
|
| 383 |
+
|
| 384 |
+
display_image = get_display_image(image_bytes, disp_w, disp_h)
|
| 385 |
|
| 386 |
def orig_to_display(rect_orig, s):
|
| 387 |
return {
|
|
|
|
| 409 |
"strokeWidth": rect_display.get("strokeWidth", 2),
|
| 410 |
}
|
| 411 |
|
| 412 |
+
# Build rectangles from state (one per field)
|
| 413 |
all_display_objects = []
|
| 414 |
+
rects_for_image = st.session_state.field_rects_orig[selected_name]
|
| 415 |
+
for fld, rect_orig in rects_for_image.items():
|
| 416 |
disp_rect = orig_to_display(rect_orig, scale)
|
| 417 |
base = fld.split(": ", 1)[1] if ": " in fld else fld
|
| 418 |
disp_rect["stroke"] = FIELD_COLORS.get(base, "#FF0000")
|
|
|
|
| 423 |
expected_count = len(all_display_objects)
|
| 424 |
|
| 425 |
rect_ver = st.session_state.rect_version[selected_name]
|
| 426 |
+
num_rects = len(rects_for_image)
|
| 427 |
canvas_key = f"canvas_{selected_name}_z{zoom}_rv{rect_ver}_n{num_rects}"
|
| 428 |
|
| 429 |
canvas_result = st_canvas(
|
|
|
|
| 439 |
key=canvas_key,
|
| 440 |
)
|
| 441 |
|
| 442 |
+
# Detect new rectangle
|
| 443 |
if canvas_result.json_data is not None:
|
| 444 |
+
objs = canvas_result.json_data.get("objects", []) or []
|
| 445 |
if len(objs) > expected_count:
|
| 446 |
new_rect_display = objs[-1]
|
| 447 |
new_rect_orig = display_to_orig(new_rect_display, scale)
|
| 448 |
new_rect_orig["stroke"] = field_color
|
| 449 |
|
| 450 |
+
# Overwrite previous rect for this field (so old one disappears)
|
| 451 |
st.session_state.field_rects_orig[selected_name][storage_field_name] = new_rect_orig
|
| 452 |
st.session_state.rect_version[selected_name] += 1
|
| 453 |
|
| 454 |
+
# Auto OCR
|
| 455 |
x1 = max(0, int(new_rect_orig["left"]))
|
| 456 |
y1 = max(0, int(new_rect_orig["top"]))
|
| 457 |
x2 = min(pil_image.width, int(new_rect_orig["left"] + new_rect_orig["width"]))
|
|
|
|
| 462 |
try:
|
| 463 |
text = pytesseract.image_to_string(crop, config="--psm 6").strip()
|
| 464 |
if text:
|
|
|
|
| 465 |
st.session_state.field_values[selected_name][storage_field_name] = text
|
| 466 |
value_state_key = f"value_{selected_name}_{storage_field_name}"
|
| 467 |
st.session_state[value_state_key] = text
|
|
|
|
| 473 |
else:
|
| 474 |
st.toast("✅ Rectangle saved")
|
| 475 |
|
| 476 |
+
# Rerun once so canvas remounts with cleaned rectangles (no old ones)
|
| 477 |
+
st.experimental_rerun()
|
| 478 |
+
|
| 479 |
+
# ---------------------------------
|
| 480 |
+
# RHS BOTTOM: OCR value, all values, export
|
| 481 |
+
# ---------------------------------
|
| 482 |
with col2:
|
| 483 |
st.markdown("#### ✏️ OCR & Value")
|
| 484 |
|
| 485 |
current_rect_orig = st.session_state.field_rects_orig[selected_name].get(storage_field_name)
|
| 486 |
value_state_key = f"value_{selected_name}_{storage_field_name}"
|
|
|
|
|
|
|
| 487 |
if value_state_key not in st.session_state:
|
| 488 |
st.session_state[value_state_key] = st.session_state.field_values[selected_name].get(
|
| 489 |
storage_field_name, ""
|
|
|
|
| 521 |
if current_rect_orig:
|
| 522 |
st.button("🗑️ Delete", on_click=delete_rect)
|
| 523 |
|
|
|
|
| 524 |
st.text_area(
|
| 525 |
"Value (auto-filled by OCR)",
|
| 526 |
key=value_state_key,
|
|
|
|
| 529 |
placeholder="Value (auto-filled by OCR)",
|
| 530 |
)
|
| 531 |
|
| 532 |
+
# All values
|
| 533 |
with st.expander("📋 All Values"):
|
| 534 |
for f in SINGLE_FIELDS:
|
| 535 |
v = st.session_state.field_values[selected_name].get(f, "")
|
|
|
|
| 547 |
for lif, v in vals:
|
| 548 |
st.write(f" {lif}: {v}")
|
| 549 |
|
| 550 |
+
# Export
|
| 551 |
st.markdown("#### 📤 JSONL Export")
|
| 552 |
|
|
|
|
| 553 |
records_all = [
|
| 554 |
build_gt_record_for_file(img["name"])
|
| 555 |
for img in images
|
|
|
|
| 557 |
]
|
| 558 |
|
| 559 |
if records_all:
|
| 560 |
+
all_jsonl_str = "\n".join(json.dumps(rec, ensure_ascii=False) for rec in records_all)
|
|
|
|
|
|
|
| 561 |
st.download_button(
|
| 562 |
"⬇️ Export ALL labeled (JSONL)",
|
| 563 |
data=all_jsonl_str.encode("utf-8"),
|
|
|
|
| 567 |
else:
|
| 568 |
st.caption("No labeled remittances yet.")
|
| 569 |
|
|
|
|
| 570 |
current_record = build_gt_record_for_file(selected_name)
|
| 571 |
with st.expander("Preview CURRENT JSON"):
|
| 572 |
st.json(current_record)
|