Mark Cuizon commited on
Commit
e400cff
·
1 Parent(s): cf264e0

fix: parse SP Lab vertical OCR layout for accurate soil structured data

Browse files

Add column-major vertical Document AI parser for SP Lab reports (parameter label then one value per sample line). Fix Total P/Available P digits being skipped as column indices. Verified exact match on all 10 samples from user OCR including N.D.

Files changed (1) hide show
  1. modules/upload.py +285 -8
modules/upload.py CHANGED
@@ -1480,24 +1480,303 @@ def _align_soil_numbers_to_params(numbers: list, params: list, sample_id: str =
1480
  return _align_numbers_to_params(numbers, params, sample_id, kind='soil')
1481
 
1482
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1483
  def _extract_sp_lab_samples_from_text(text: str, sample_prefix: str, params: list) -> dict:
1484
  """
1485
  Parse SP Lab samples (e.g. S218/25 or P220/25) from real OCR text.
1486
  Never uses hardcoded values — structured data must match Raw OCR Text.
1487
 
1488
  Handles:
 
1489
  - Row-per-sample: S218/25 5.26 0.12 0.91 ...
1490
- - Column-major / transposed (common Document AI reading order):
1491
- S218/25 S219/25 S220/25
1492
- pH 5.26 5.10 4.88
1493
- N 0.12 0.11 0.08
1494
- ...
1495
  - Leading junk numbers before pH (lab serial bleeding into values)
1496
  """
1497
  samples = {}
1498
  if not text:
1499
  return samples
1500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1501
  lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
1502
  joined = ' '.join(lines)
1503
  joined_norm = re.sub(r'[^\w\s\.\-\(\)\[\]\/<>%]', ' ', joined)
@@ -1527,9 +1806,7 @@ def _extract_sp_lab_samples_from_text(text: str, sample_prefix: str, params: lis
1527
  is_soil = bool(params and params[0] == 'pH')
1528
 
1529
  # ------------------------------------------------------------------
1530
- # Strategy T (priority): transposed / column-major SP Lab layout
1531
- # Sample IDs appear first (often one line), then parameter rows with
1532
- # one value per sample in the same column order.
1533
  # ------------------------------------------------------------------
1534
  def _parse_transposed() -> dict:
1535
  # Collect ordered unique sample IDs as they appear
 
1480
  return _align_numbers_to_params(numbers, params, sample_id, kind='soil')
1481
 
1482
 
1483
+ def _parse_sp_lab_vertical_document(text: str, sample_prefix: str, params: list) -> dict:
1484
+ """Parse SP Lab reports where Document AI dumps one token per line.
1485
+
1486
+ Real layout (user OCR)::
1487
+
1488
+ S218/25
1489
+ S219/25
1490
+ ...
1491
+ 1
1492
+ 2
1493
+ 3
1494
+ PH
1495
+ 5.0
1496
+ 4.3
1497
+ ...
1498
+ Nitrogen
1499
+ 0.10
1500
+ ...
1501
+
1502
+ Sample IDs form columns; each parameter label is followed by one value
1503
+ per sample (same order). Multiple table sections are supported.
1504
+ """
1505
+ if not text:
1506
+ return {}
1507
+
1508
+ lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
1509
+ prefix = sample_prefix.upper()
1510
+ id_re = re.compile(rf'^{prefix}\d{{1,4}}\s*/\s*\d{{2}}$', re.I)
1511
+ id_find = re.compile(rf'\b({prefix}\d{{1,4}}\s*/\s*\d{{2}})\b', re.I)
1512
+
1513
+ def canon(sid: str) -> str:
1514
+ sid = re.sub(r'\s+', '', sid.strip())
1515
+ return sid[0].upper() + sid[1:] if sid else sid
1516
+
1517
+ # Soil / leaf parameter label matchers (order matters for specificity)
1518
+ if params and params[0] == 'pH':
1519
+ label_specs = [
1520
+ (re.compile(r'^pH$', re.I), 'pH'),
1521
+ (re.compile(r'^PH$', re.I), 'pH'),
1522
+ (re.compile(r'^Nitrogen$', re.I), 'N (%)'),
1523
+ (re.compile(r'^N\s*\(%\)$', re.I), 'N (%)'),
1524
+ (re.compile(r'^Organic\s*Carbon$', re.I), 'Org. C (%)'),
1525
+ (re.compile(r'^Org\.?\s*C', re.I), 'Org. C (%)'),
1526
+ (re.compile(r'^Total\s*P', re.I), 'Total P (mg/kg)'),
1527
+ (re.compile(r'^Available\s*P', re.I), 'Avail P (mg/kg)'),
1528
+ (re.compile(r'^Avail\.?\s*P', re.I), 'Avail P (mg/kg)'),
1529
+ (re.compile(r'^Exch\.?\s*K', re.I), 'Exch. K (meq%)'),
1530
+ (re.compile(r'^Exch\.?\s*Ca', re.I), 'Exch. Ca (meq%)'),
1531
+ (re.compile(r'^Exch\.?\s*Mg', re.I), 'Exch. Mg (meq%)'),
1532
+ (re.compile(r'^C\.?E\.?C\.?$', re.I), 'CEC (meq%)'),
1533
+ ]
1534
+ else:
1535
+ label_specs = [
1536
+ (re.compile(r'^N(?:itrogen)?$', re.I), 'N (%)'),
1537
+ (re.compile(r'^P(?:hos(?:phorus)?)?$', re.I), 'P (%)'),
1538
+ (re.compile(r'^K(?:alium|potassium)?$', re.I), 'K (%)'),
1539
+ (re.compile(r'^Mg(?:agnesium)?$', re.I), 'Mg (%)'),
1540
+ (re.compile(r'^Ca(?:lcium)?$', re.I), 'Ca (%)'),
1541
+ (re.compile(r'^B(?:oron)?$', re.I), 'B (mg/kg)'),
1542
+ (re.compile(r'^Cu(?:pper)?$', re.I), 'Cu (mg/kg)'),
1543
+ (re.compile(r'^Zn(?:inc)?$', re.I), 'Zn (mg/kg)'),
1544
+ ]
1545
+
1546
+ skip_labels = re.compile(
1547
+ r'^(Exchangeable|Parameter|Lab\s*No.*|Analysis|Date|Page|Serial|'
1548
+ r'Your\s*Ref|TEST\s*REPORT|SP\s*LAB|Sarawak|Non-?Detected|'
1549
+ r'%|mg/kg|meq%|meq/?%?)$',
1550
+ re.I,
1551
+ )
1552
+
1553
+ def match_param_label(line: str):
1554
+ for preg, key in label_specs:
1555
+ if preg.match(line.strip()):
1556
+ return key
1557
+ return None
1558
+
1559
+ def is_pure_index(line: str) -> bool:
1560
+ """Column index rows like 1,2,3...10 that appear under sample IDs."""
1561
+ return bool(re.fullmatch(r'\d{1,2}', line.strip()))
1562
+
1563
+ def is_value_token(line: str) -> bool:
1564
+ s = line.strip()
1565
+ if not s:
1566
+ return False
1567
+ if id_re.match(s) or id_find.search(s):
1568
+ return False
1569
+ if match_param_label(s):
1570
+ return False
1571
+ if skip_labels.match(s):
1572
+ return False
1573
+ # N.D. / ND
1574
+ if re.fullmatch(r'N\.?\s*D\.?', s, re.I):
1575
+ return True
1576
+ if re.fullmatch(r'<?\d+\.?\d*', s):
1577
+ return True
1578
+ return False
1579
+
1580
+ def parse_value(line: str):
1581
+ s = line.strip()
1582
+ if re.fullmatch(r'N\.?\s*D\.?', s, re.I):
1583
+ return 0.0
1584
+ return _parse_numeric_token(s)
1585
+
1586
+ # Split document into table sections (each starts near sample IDs after "Lab No" / "Parameter")
1587
+ sections = []
1588
+ current = []
1589
+ for line in lines:
1590
+ if re.search(r'Lab\s*No', line, re.I) and current:
1591
+ sections.append(current)
1592
+ current = [line]
1593
+ else:
1594
+ current.append(line)
1595
+ if current:
1596
+ sections.append(current)
1597
+
1598
+ # If no clear section markers, treat whole doc as one section
1599
+ if len(sections) <= 1:
1600
+ # Also try splitting when a new block of sample IDs appears after parameters
1601
+ sections = [lines]
1602
+
1603
+ all_samples = {}
1604
+
1605
+ def parse_section(sec_lines: list) -> dict:
1606
+ # Collect sample IDs in order of appearance (within this section)
1607
+ sample_ids = []
1608
+ seen = set()
1609
+ for line in sec_lines:
1610
+ for m in id_find.finditer(line):
1611
+ sid = canon(m.group(1))
1612
+ if sid not in seen:
1613
+ seen.add(sid)
1614
+ sample_ids.append(sid)
1615
+ if len(sample_ids) < 1:
1616
+ return {}
1617
+
1618
+ n = len(sample_ids)
1619
+ # param -> list of n values
1620
+ param_cols = {p: None for p in params}
1621
+
1622
+ i = 0
1623
+ while i < len(sec_lines):
1624
+ line = sec_lines[i]
1625
+ # Skip sample id lines, indices, units, headers
1626
+ if id_find.search(line) and not match_param_label(line):
1627
+ i += 1
1628
+ continue
1629
+ if is_pure_index(line):
1630
+ i += 1
1631
+ continue
1632
+ if skip_labels.match(line):
1633
+ i += 1
1634
+ continue
1635
+
1636
+ pkey = match_param_label(line)
1637
+ if pkey and pkey in param_cols:
1638
+ # Collect the next `n` value tokens after this parameter label.
1639
+ # IMPORTANT: after a label, digits like 59 / 2 / 1 are REAL lab
1640
+ # values (Total P, Available P), NOT column indices 1..10.
1641
+ values = []
1642
+ j = i + 1
1643
+ while j < len(sec_lines) and len(values) < n:
1644
+ nxt = sec_lines[j]
1645
+ # Stop if we hit next param label
1646
+ if match_param_label(nxt):
1647
+ break
1648
+ # Units / section words only — do not treat digits as indices here
1649
+ if skip_labels.match(nxt):
1650
+ j += 1
1651
+ # If we already have values and hit a unit line, we're done
1652
+ if values:
1653
+ break
1654
+ continue
1655
+ if id_find.search(nxt):
1656
+ break
1657
+ if re.fullmatch(r'N\.?\s*D\.?', nxt, re.I) or re.fullmatch(r'<?\d+\.?\d*', nxt):
1658
+ val = parse_value(nxt)
1659
+ if val is not None:
1660
+ values.append(val)
1661
+ j += 1
1662
+ else:
1663
+ j += 1
1664
+ if values:
1665
+ break
1666
+ if len(values) >= n:
1667
+ param_cols[pkey] = values[:n]
1668
+ elif len(values) > 0:
1669
+ # Accept partial only if nearly complete (e.g. trailing missing)
1670
+ if len(values) >= n - 1:
1671
+ param_cols[pkey] = values
1672
+ i = j
1673
+ continue
1674
+ i += 1
1675
+
1676
+ # Require at least pH/N + a few more params filled
1677
+ filled = [p for p in params if param_cols.get(p)]
1678
+ if len(filled) < 3:
1679
+ return {}
1680
+
1681
+ # Normalize lengths: use min available
1682
+ out = {sid: {} for sid in sample_ids}
1683
+ for p in params:
1684
+ col = param_cols.get(p)
1685
+ if not col:
1686
+ continue
1687
+ for idx, sid in enumerate(sample_ids):
1688
+ if idx < len(col):
1689
+ out[sid][p] = col[idx]
1690
+ # Drop empty
1691
+ return {sid: data for sid, data in out.items() if data}
1692
+
1693
+ # Parse each Lab-No section separately (critical: each table has its own
1694
+ # sample count — do NOT parse the whole doc as one 10-column table).
1695
+ for sec in sections:
1696
+ part = parse_section(sec)
1697
+ for sid, data in part.items():
1698
+ if sid not in all_samples:
1699
+ all_samples[sid] = data
1700
+ else:
1701
+ # Merge params; prefer non-empty / longer
1702
+ for k, v in data.items():
1703
+ if k not in all_samples[sid]:
1704
+ all_samples[sid][k] = v
1705
+
1706
+ # If Lab-No split failed, try sample-id cluster split
1707
+ if len(all_samples) < 2:
1708
+ clusters = []
1709
+ cluster = []
1710
+ saw_id = False
1711
+ for line in lines:
1712
+ has_id = bool(id_find.search(line))
1713
+ if has_id:
1714
+ if saw_id is False and cluster and any(id_find.search(x) for x in cluster):
1715
+ # new cluster after params
1716
+ clusters.append(cluster)
1717
+ cluster = []
1718
+ saw_id = True
1719
+ cluster.append(line)
1720
+ else:
1721
+ if cluster:
1722
+ cluster.append(line)
1723
+ # reset saw_id after we leave a run of IDs and hit a param
1724
+ if match_param_label(line):
1725
+ saw_id = False
1726
+ if cluster:
1727
+ clusters.append(cluster)
1728
+ for cl in clusters:
1729
+ part = parse_section(cl)
1730
+ all_samples.update(part)
1731
+
1732
+ # Last resort: whole document only if still empty
1733
+ if not all_samples:
1734
+ all_samples = parse_section(lines)
1735
+
1736
+ return all_samples
1737
+
1738
+
1739
  def _extract_sp_lab_samples_from_text(text: str, sample_prefix: str, params: list) -> dict:
1740
  """
1741
  Parse SP Lab samples (e.g. S218/25 or P220/25) from real OCR text.
1742
  Never uses hardcoded values — structured data must match Raw OCR Text.
1743
 
1744
  Handles:
1745
+ - Vertical Document AI dumps (one token per line) — SP Lab official layout
1746
  - Row-per-sample: S218/25 5.26 0.12 0.91 ...
1747
+ - Column-major / transposed (samples on one line, params as rows)
 
 
 
 
1748
  - Leading junk numbers before pH (lab serial bleeding into values)
1749
  """
1750
  samples = {}
1751
  if not text:
1752
  return samples
1753
 
1754
+ # ------------------------------------------------------------------
1755
+ # Strategy V (highest priority): vertical one-token-per-line SP Lab OCR
1756
+ # ------------------------------------------------------------------
1757
+ vertical = _parse_sp_lab_vertical_document(text, sample_prefix, params)
1758
+ if vertical:
1759
+ # Validate soil pH columns look sane before accepting
1760
+ if params and params[0] == 'pH':
1761
+ ph_ok = sum(
1762
+ 1 for d in vertical.values()
1763
+ if isinstance(d.get('pH'), (int, float)) and 2.5 <= d['pH'] <= 9.5
1764
+ )
1765
+ if ph_ok >= max(1, len(vertical) // 2):
1766
+ return vertical
1767
+ else:
1768
+ # leaf: N in range
1769
+ n_ok = sum(
1770
+ 1 for d in vertical.values()
1771
+ if isinstance(d.get('N (%)'), (int, float)) and 1.2 <= d['N (%)'] <= 4.0
1772
+ )
1773
+ if n_ok >= max(1, len(vertical) // 2) or len(vertical) >= 1:
1774
+ return vertical
1775
+ # even if validation weak, prefer vertical if it found many samples with many params
1776
+ avg_params = sum(len(d) for d in vertical.values()) / max(len(vertical), 1)
1777
+ if len(vertical) >= 3 and avg_params >= 5:
1778
+ return vertical
1779
+
1780
  lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
1781
  joined = ' '.join(lines)
1782
  joined_norm = re.sub(r'[^\w\s\.\-\(\)\[\]\/<>%]', ' ', joined)
 
1806
  is_soil = bool(params and params[0] == 'pH')
1807
 
1808
  # ------------------------------------------------------------------
1809
+ # Strategy T: transposed / column-major SP Lab layout (values on same line)
 
 
1810
  # ------------------------------------------------------------------
1811
  def _parse_transposed() -> dict:
1812
  # Collect ordered unique sample IDs as they appear