Ankushbl6 commited on
Commit
99902ac
·
verified ·
1 Parent(s): 8b9a0d2

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. 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
- # Tesseract is installed via packages.txt on HuggingFace Spaces (Linux)
11
- # No need to set path - it's in system PATH
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
- # ---- Define fields ----
 
 
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
- # ----- JSONL schema helper mappings -----
 
 
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
- # ---- Session state ----
 
 
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
- # --- Helper functions ---
 
 
138
  @st.cache_data
139
- def load_image(file_content):
140
  return Image.open(BytesIO(file_content)).convert("RGB")
141
 
142
- def get_display_image_from_bytes(image_bytes, width, height):
143
- """Create fresh display image from bytes - no caching to avoid stale PIL references"""
 
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
- # --- Upload (compact) ---
 
 
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 for this image
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
- # ========== MAIN COLUMNS ==========
 
 
259
  col1, col2 = st.columns([3, 2])
260
 
261
- # Initialize field variables with defaults
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
- # ====== RHS TOP: FIELD SELECTION + ZOOM ======
 
 
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="zoom_out", help="Zoom out", on_click=do_zoom_out)
364
-
365
  with zoom_col3:
366
- st.button("➕", key="zoom_in", help="Zoom in", on_click=do_zoom_in)
367
-
368
  with zoom_col4:
369
- st.button("Fit", key="zoom_fit", help="Fit to screen", on_click=do_zoom_fit)
370
 
371
  st.caption(f"Original: {pil_image.width}×{pil_image.height}")
372
 
373
- # ====== LHS: CANVAS / IMAGE ======
 
 
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 = get_display_image_from_bytes(image_bytes, disp_w, disp_h)
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 display objects from stored rectangles
409
  all_display_objects = []
410
- for fld, rect_orig in st.session_state.field_rects_orig[selected_name].items():
 
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(st.session_state.field_rects_orig[selected_name])
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 and auto-OCR
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
- # overwrite previous rect for this field
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
- # ====== RHS BOTTOM: OCR VALUE + EXPORT ======
 
 
 
 
 
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
- # ========== ALL VALUES SECTION ==========
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
- # ========== EXPORT SECTION ==========
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)