feat: user code changes merged

This commit is contained in:
Deep Koluguri 2026-06-12 21:56:11 -04:00
parent 33f06cd51d
commit 817931fdbf
16 changed files with 906 additions and 555 deletions

View File

@ -312,7 +312,7 @@ def separator_barriers(png_path, regions, near=80, min_overlap=0.3,
# Column width (median text-region width). A horizontal line counts as an article # Column width (median text-region width). A horizontal line counts as an article
# separator only when it spans most of a column. NOTE: a strict ≥1.0× column floor is # separator only when it spans most of a column. NOTE: a strict ≥1.0× column floor is
# too aggressive — a SINGLE-column article's own boundary rule is itself <1 column wide, # too aggressive — a SINGLE-column article's own boundary rule is itself <1 column wide,
# so ≥1.0× drops real separators and merges articles (measured: NT pg4 R121 2 datelines). # so ≥1.0× drops real separators and merges articles (measured: NT pg4 R121 -> 2 datelines).
# 0.6× column is the safe floor: it removes only the short caption / decorative underlines # 0.6× column is the safe floor: it removes only the short caption / decorative underlines
# and merges NO articles on AJ / NT / JG. # and merges NO articles on AJ / NT / JG.
_twid = sorted((r["bbox"][2] - r["bbox"][0]) for r in regions if r.get("type") == "text") _twid = sorted((r["bbox"][2] - r["bbox"][0]) for r in regions if r.get("type") == "text")
@ -363,7 +363,7 @@ def separator_barriers(png_path, regions, near=80, min_overlap=0.3,
continue continue
b = r["bbox"] b = r["bbox"]
if (b[2] - b[0]) < multicol_frac * W: if (b[2] - b[0]) < multicol_frac * W:
continue # single-column header skip continue # single-column header -> skip
yb = _faint_rule_above(gray, b) yb = _faint_rule_above(gray, b)
if yb is not None and not _lead_photo_above(yb, b[0], b[2]): if yb is not None and not _lead_photo_above(yb, b[0], b[2]):
bars.append({"y": yb, "x1": b[0], "x2": b[2], bars.append({"y": yb, "x1": b[0], "x2": b[2],
@ -377,7 +377,7 @@ def separator_barriers(png_path, regions, near=80, min_overlap=0.3,
continue continue
b = r["bbox"] b = r["bbox"]
if (b[2] - b[0]) >= multicol_frac * W: if (b[2] - b[0]) >= multicol_frac * W:
continue # multi-column handled above continue # multi-column -> handled above
yb = _faint_grey_band_above(gray, b) yb = _faint_grey_band_above(gray, b)
if yb is not None and not _lead_photo_above(yb, b[0], b[2]): if yb is not None and not _lead_photo_above(yb, b[0], b[2]):
bars.append({"y": yb, "x1": b[0], "x2": b[2]}) bars.append({"y": yb, "x1": b[0], "x2": b[2]})

View File

@ -58,7 +58,7 @@ def run(pdir, pg, paper, tag):
and max(0, min(y2, w["y2"]) - max(y1, w["y1"])) >= 0.4 * bh] and max(0, min(y2, w["y2"]) - max(y1, w["y1"])) >= 0.4 * bh]
right = min(rcand) if rcand else cright right = min(rcand) if rcand else cright
right = max(right, x2) # never shrink right = max(right, x2) # never shrink
# topmost dead-end: top-row article up to masthead bottom # topmost dead-end: top-row article -> up to masthead bottom
top = mh if (y1 <= top_row_cut and mh > 0) else y1 top = mh if (y1 <= top_row_cut and mh > 0) else y1
top = min(top, y1) # never shrink downward top = min(top, y1) # never shrink downward
return [x1, top, right, y2] return [x1, top, right, y2]

View File

@ -45,7 +45,7 @@ for paper, RUN, pages in RUNS:
ds = se.find_article_starts_by_dateline(regs, png, paper) ds = se.find_article_starts_by_dateline(regs, png, paper)
recs = se.crop_political_articles(png, political, regs, OUT, pg, recs = se.crop_political_articles(png, political, regs, OUT, pg,
dateline_starts=ds, sep_lines=bars) dateline_starts=ds, sep_lines=bars)
print(f"=== {RUN.name} page {pg}: {len(political)} political {len(recs)} crops") print(f"=== {RUN.name} page {pg}: {len(political)} political -> {len(recs)} crops")
for l in buf.getvalue().splitlines(): for l in buf.getvalue().splitlines():
if any(k in l for k in ("Block-snap", "Cropped:", "Clipped", "Floored", "Grew", if any(k in l for k in ("Block-snap", "Cropped:", "Clipped", "Floored", "Grew",
"Banner", "block-snap unavailable")): "Banner", "block-snap unavailable")):

11
app.py
View File

@ -33,7 +33,16 @@ from flask import (
send_from_directory, send_file, abort, jsonify, send_from_directory, send_file, abort, jsonify,
) )
from extractor import process_pdf, JOBS, JOB_LOCK import sys
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8')
from extractor import process_pdf
JOBS = {}
JOB_LOCK = threading.Lock()
ROOT = Path(__file__).parent ROOT = Path(__file__).parent
UPLOAD_DIR = ROOT / "uploads" UPLOAD_DIR = ROOT / "uploads"

1
app_py_utf8.txt Normal file
View File

@ -0,0 +1 @@
None

File diff suppressed because it is too large Load Diff

11
fix_pdf.py Normal file
View File

@ -0,0 +1,11 @@
import json
from pathlib import Path
from generate_pdf import generate_political_pdf
job_dir = Path(r'c:\Users\sunde\proxmox\news-scan\output\20260612_131909')
articles = json.loads((job_dir / 'political_articles.json').read_text(encoding='utf-8'))
for a in articles:
a['image_file'] = f"{a['id']}.png"
generate_political_pdf(articles, job_dir)
print("Regenerated PDF with images!")

View File

@ -0,0 +1,71 @@
import json
from pathlib import Path
from fpdf import FPDF
import unicodedata
def clean_text(text):
if not text:
return "N/A"
# Replace unicode quotes and dashes with ascii equivalents to avoid FPDF errors with Latin-1
text = str(text)
text = text.replace('"', '"').replace('"', '"').replace('"', "'").replace('"', "'")
text = text.replace('', '-').replace('', '-')
# Normalize to nearest ascii character
res = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('ascii')
res = res.strip()
if not res:
return "N/A"
# Break up extremely long words that crash FPDF
res = " ".join([word[:50] for word in res.split()])
return res
def generate_political_pdf(selected_articles, job_dir):
job_dir = Path(job_dir)
images_dir = job_dir / "all_political_images"
if not images_dir.exists() or not selected_articles:
return
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=15)
# Sort articles: high priority first
high = [a for a in selected_articles if a.get("priority") == "high"]
medium = [a for a in selected_articles if a.get("priority") == "medium"]
sorted_articles = high + medium
for i, art in enumerate(sorted_articles, 1):
pdf.add_page()
# Add Header
pdf.set_font("helvetica", "B", 16)
priority = str(art.get("priority", "N/A")).upper()
pdf.cell(0, 10, f"Article {i} - {priority} PRIORITY", new_x="LMARGIN", new_y="NEXT", align="C")
pdf.ln(5)
# Details
pdf.set_font("helvetica", "B", 12)
pdf.multi_cell(0, 8, f"Headline: {clean_text(art.get('headline_english'))}", new_x="LMARGIN", new_y="NEXT")
pdf.multi_cell(0, 8, f"Category: {clean_text(art.get('category')).upper()}", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("helvetica", "", 12)
pdf.multi_cell(0, 8, f"Why: {clean_text(art.get('reasoning'))}", new_x="LMARGIN", new_y="NEXT")
pdf.ln(5)
# Image
img_name = art.get("image_file")
if img_name:
img_path = images_dir / img_name
if img_path.exists():
# Get image dimensions to scale it properly if needed
try:
pdf.image(str(img_path), w=180)
except Exception as e:
print(f"Error adding image to PDF: {e}")
output_path = job_dir / "political_articles_insights.pdf"
try:
pdf.output(str(output_path))
print(f"Successfully generated PDF report at {output_path}")
except Exception as e:
print(f"Failed to generate PDF: {e}")

170
geometric_grouper.py Normal file
View File

@ -0,0 +1,170 @@
import math
def get_horizontal_overlap(bbox1, bbox2):
x_left = max(bbox1[0], bbox2[0])
x_right = min(bbox1[2], bbox2[2])
return max(0, x_right - x_left)
def group_surya_regions_geometrically(regions):
"""
Groups regions spatially.
Instead of a linear reading order, this associates text and images
with the headline that is directly above them in the same column.
"""
if not regions:
return []
titles = [r for r in regions if r['type'] in ('doc_title', 'paragraph_title')]
non_titles = [r for r in regions if r['type'] not in ('doc_title', 'paragraph_title')]
# If no titles exist, return everything as one article
if not titles:
member_ids = [r['id'] for r in regions]
min_x = min(r['bbox'][0] for r in regions)
min_y = min(r['bbox'][1] for r in regions)
max_x = max(r['bbox'][2] for r in regions)
max_y = max(r['bbox'][3] for r in regions)
return [{
"article_id": 1,
"title_region": None,
"member_regions": member_ids,
"bbox": [min_x, min_y, max_x, max_y],
"grouped_by": "geometric_fallback"
}]
article_groups = {t['id']: [t] for t in titles}
for item in non_titles:
item_bbox = item['bbox']
item_cy = (item_bbox[1] + item_bbox[3]) / 2
item_cx = (item_bbox[0] + item_bbox[2]) / 2
item_width = item_bbox[2] - item_bbox[0]
best_title = None
# -------------------------------------------------------------
# Rule 1: Find titles ABOVE the item that share the same column
# -------------------------------------------------------------
candidates_above = []
for t in titles:
t_bbox = t['bbox']
if t_bbox[1] <= item_cy:
overlap = get_horizontal_overlap(item_bbox, t_bbox)
t_width = t_bbox[2] - t_bbox[0]
t_cx = (t_bbox[0] + t_bbox[2]) / 2
if overlap > (item_width * 0.1) or overlap > (t_width * 0.1) or abs(item_cx - t_cx) < 150:
distance = item_bbox[1] - t_bbox[3]
if distance < 1500: # MAX DISTANCE ABOVE
candidates_above.append(t)
if candidates_above:
best_title = min(candidates_above, key=lambda t: abs(item_bbox[1] - t['bbox'][3]))
else:
# -------------------------------------------------------------
# Rule 2: If no title is above, find titles BELOW the item
# -------------------------------------------------------------
candidates_below = []
for t in titles:
t_bbox = t['bbox']
if t_bbox[1] > item_cy:
overlap = get_horizontal_overlap(item_bbox, t_bbox)
t_width = t_bbox[2] - t_bbox[0]
t_cx = (t_bbox[0] + t_bbox[2]) / 2
if overlap > (item_width * 0.1) or overlap > (t_width * 0.1) or abs(item_cx - t_cx) < 150:
distance = t_bbox[1] - item_bbox[3]
if distance < 800: # MAX DISTANCE BELOW (stricter because text usually flows down, not up)
candidates_below.append(t)
if candidates_below:
best_title = min(candidates_below, key=lambda t: abs(t['bbox'][1] - item_bbox[3]))
else:
# -------------------------------------------------------------
# Rule 3: Absolute fallback. No column overlap at all.
# Find the absolute closest title via Euclidean distance.
# -------------------------------------------------------------
closest_t = min(titles, key=lambda t: math.hypot(
item_cx - ((t['bbox'][0] + t['bbox'][2]) / 2),
item_cy - ((t['bbox'][1] + t['bbox'][3]) / 2)
))
# Only attach if it's reasonably close, else leave as orphan
dist = math.hypot(item_cx - ((closest_t['bbox'][0] + closest_t['bbox'][2]) / 2),
item_cy - ((closest_t['bbox'][1] + closest_t['bbox'][3]) / 2))
if dist < 1500:
best_title = closest_t
if best_title:
article_groups[best_title['id']].append(item)
else:
# Treat as an orphan (create a new article group for it, or group orphans together)
# For simplicity, we'll assign it a virtual title ID based on its own ID so it becomes a standalone article
virtual_id = f"orphan_{item['id']}"
article_groups[virtual_id] = [item]
# Format output
articles = []
# Sort groups top-to-bottom, left-to-right based on title position
sorted_titles = sorted(titles, key=lambda t: (t['bbox'][1], t['bbox'][0]))
for idx, (title_id, group_regions) in enumerate(article_groups.items(), start=1):
if not group_regions:
continue
min_x = min(r['bbox'][0] for r in group_regions)
min_y = min(r['bbox'][1] for r in group_regions)
max_x = max(r['bbox'][2] for r in group_regions)
max_y = max(r['bbox'][3] for r in group_regions)
# Determine if it's an orphan group
is_orphan = str(title_id).startswith("orphan_")
articles.append({
"article_id": idx,
"title_region": None if is_orphan else title_id,
"member_regions": [r['id'] for r in group_regions],
"bbox": [min_x, min_y, max_x, max_y],
"grouped_by": "geometric_orphan" if is_orphan else "geometric"
})
# We should merge orphans that are vertically adjacent into the same orphan group to prevent shattering
# Simple vertical merge for orphans
orphan_articles = [a for a in articles if a['grouped_by'] == 'geometric_orphan']
valid_articles = [a for a in articles if a['grouped_by'] == 'geometric']
# Sort orphans top-to-bottom
orphan_articles.sort(key=lambda a: a['bbox'][1])
merged_orphans = []
current_orphan = None
for o in orphan_articles:
if not current_orphan:
current_orphan = o
continue
# Check if they overlap horizontally and are close vertically
overlap = get_horizontal_overlap(current_orphan['bbox'], o['bbox'])
vertical_dist = o['bbox'][1] - current_orphan['bbox'][3]
if overlap > 0 and vertical_dist < 400:
# Merge
current_orphan['member_regions'].extend(o['member_regions'])
current_orphan['bbox'][0] = min(current_orphan['bbox'][0], o['bbox'][0])
current_orphan['bbox'][1] = min(current_orphan['bbox'][1], o['bbox'][1])
current_orphan['bbox'][2] = max(current_orphan['bbox'][2], o['bbox'][2])
current_orphan['bbox'][3] = max(current_orphan['bbox'][3], o['bbox'][3])
else:
merged_orphans.append(current_orphan)
current_orphan = o
if current_orphan:
merged_orphans.append(current_orphan)
final_articles = valid_articles + merged_orphans
# Reassign IDs
for idx, a in enumerate(final_articles, start=1):
a['article_id'] = idx
return final_articles

BIN
original_app_py.txt Normal file

Binary file not shown.

View File

@ -1,4 +1,10 @@
import os import os
import sys
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8')
import shutil import shutil
import argparse import argparse
from pathlib import Path from pathlib import Path
@ -87,7 +93,7 @@ def main():
timestamp = datetime.now().strftime("%H%M%S") timestamp = datetime.now().strftime("%H%M%S")
dest_path = processed_dir / f"{pdf_path.stem}_{timestamp}{pdf_path.suffix}" dest_path = processed_dir / f"{pdf_path.stem}_{timestamp}{pdf_path.suffix}"
shutil.move(str(pdf_path), str(dest_path)) shutil.move(str(pdf_path.absolute()), str(dest_path.absolute()))
print(f"\nMoved processed file to: {dest_path}") print(f"\nMoved processed file to: {dest_path}")
# Check if the PDF report was generated # Check if the PDF report was generated

View File

@ -3,7 +3,8 @@
<section class="card"> <section class="card">
<h1>Upload a PDF</h1> <h1>Upload a PDF</h1>
<p class="muted">Each page will be rendered to fit inside the pixel box below, then split into per-article images and text.</p> <p class="muted">Each page will be rendered to fit inside the pixel box below, then split into per-article images and
text.</p>
<form action="{{ url_for('upload') }}" method="post" enctype="multipart/form-data"> <form action="{{ url_for('upload') }}" method="post" enctype="multipart/form-data">
<label class="file"> <label class="file">
<span>PDF file</span> <span>PDF file</span>
@ -26,26 +27,33 @@
<section class="card"> <section class="card">
<h2>Recent jobs</h2> <h2>Recent jobs</h2>
{% if jobs %} {% if jobs %}
<table> <table>
<thead> <thead>
<tr><th>ID</th><th>File</th><th>Pages</th><th>Articles</th><th>Status</th><th></th></tr> <tr>
</thead> <th>ID</th>
<tbody> <th>File</th>
{% for j in jobs %} <th>Pages</th>
<tr> <th>Articles</th>
<td><code>{{ j.id }}</code></td> <th>Status</th>
<td>{{ j.filename }}</td> <th></th>
<td>{{ j.pages }}</td> </tr>
<td>{{ j.articles }}</td> </thead>
<td><span class="status status-{{ j.status }}">{{ j.status }}</span></td> <tbody>
<td><a href="{{ url_for('job_view', job_id=j.id) }}">Open</a></td> {% for j in jobs %}
</tr> <tr>
{% endfor %} <td><code>{{ j.id }}</code></td>
</tbody> <td>{{ j.filename }}</td>
</table> <td>{{ j.pages }}</td>
<td>{{ j.articles }}</td>
<td><span class="status status-{{ j.status }}">{{ j.status }}</span></td>
<td><a href="{{ url_for('job_view', job_id=j.id) }}">Open</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %} {% else %}
<p class="muted">No jobs yet.</p> <p class="muted">No jobs yet.</p>
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}

View File

@ -26,7 +26,7 @@
<div class="art"> <div class="art">
<div class="art-img"> <div class="art-img">
{% if a.has_image %} {% if a.has_image %}
<img src="{{ url_for('article_image', job_id=job_id, art_name=a.name) }}" alt=""> <img src="{{ url_for('article_image', job_id=job_id, art_name=a.name) }}" alt="">
{% endif %} {% endif %}
</div> </div>
<div class="art-meta"> <div class="art-meta">
@ -35,7 +35,8 @@
{% if a.dateline %}<div class="dateline">{{ a.dateline }}</div>{% endif %} {% if a.dateline %}<div class="dateline">{{ a.dateline }}</div>{% endif %}
{% if a.headline_preview %}<div class="head">{{ a.headline_preview }}</div>{% endif %} {% if a.headline_preview %}<div class="head">{{ a.headline_preview }}</div>{% endif %}
<div class="row"> <div class="row">
{% if a.has_image %}<a href="{{ url_for('article_image', job_id=job_id, art_name=a.name) }}">image</a>{% endif %} {% if a.has_image %}<a href="{{ url_for('article_image', job_id=job_id, art_name=a.name) }}">image</a>{% endif
%}
{% if a.has_text %}<a href="{{ url_for('article_text', job_id=job_id, art_name=a.name) }}">text</a>{% endif %} {% if a.has_text %}<a href="{{ url_for('article_text', job_id=job_id, art_name=a.name) }}">text</a>{% endif %}
</div> </div>
</div> </div>
@ -46,31 +47,31 @@
{% endif %} {% endif %}
<script> <script>
const status = document.getElementById('status'); const status = document.getElementById('status');
const msg = document.getElementById('message'); const msg = document.getElementById('message');
const pagecount = document.getElementById('pagecount'); const pagecount = document.getElementById('pagecount');
const artcount = document.getElementById('artcount'); const artcount = document.getElementById('artcount');
async function poll() { async function poll() {
if (status.textContent === 'done' || status.textContent === 'error') return; if (status.textContent === 'done' || status.textContent === 'error') return;
try { try {
const r = await fetch("{{ url_for('job_status', job_id=job_id) }}"); const r = await fetch("{{ url_for('job_status', job_id=job_id) }}");
const data = await r.json(); const data = await r.json();
status.textContent = data.status || 'unknown'; status.textContent = data.status || 'unknown';
status.className = 'status status-' + (data.status || 'unknown'); status.className = 'status status-' + (data.status || 'unknown');
msg.textContent = data.message ? '· ' + data.message : ''; msg.textContent = data.message ? '· ' + data.message : '';
if (data.meta) { if (data.meta) {
if (data.meta.pages) pagecount.textContent = data.meta.pages; if (data.meta.pages) pagecount.textContent = data.meta.pages;
if (data.meta.articles) artcount.textContent = data.meta.articles; if (data.meta.articles) artcount.textContent = data.meta.articles;
} }
if (data.status === 'done') { if (data.status === 'done') {
setTimeout(() => location.reload(), 800); setTimeout(() => location.reload(), 800);
return; return;
} }
} catch (e) {} } catch (e) { }
setTimeout(poll, 1500); setTimeout(poll, 1500);
} }
poll(); poll();
</script> </script>
{% endblock %} {% endblock %}

24
test_fpdf_real.py Normal file
View File

@ -0,0 +1,24 @@
import json
import glob
from fpdf import FPDF
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=15)
for f in glob.glob('output/20260612_110447/articles/*/info.json'):
info = json.loads(open(f, encoding='utf-8').read())
if not info.get('priority'):
continue
pdf.add_page()
pdf.set_font("helvetica", "B", 16)
pdf.cell(0, 10, "Header", ln=True)
pdf.set_font("helvetica", "B", 12)
pdf.multi_cell(0, 8, f"Headline: {info.get('headline_english', '')}")
print(f"Testing Category: {info.get('category', '')}")
pdf.multi_cell(0, 8, f"Category: {str(info.get('category', '')).upper()}")
pdf.multi_cell(0, 8, f"Why: {info.get('reasoning', '')}")
pdf.output('test_real.pdf')
print("SUCCESS!")

View File

@ -0,0 +1 @@
"from pathlib import Path\nfrom generate_pdf import generate_political_pdf\n\njob_dir = Path(r\"c:\\Users\\sunde\\proxmox\\news-scan\\output\\20260611_161537\")\n\nmock_selected = [\n {\n \"id\": \"p001_a002\",\n \"headline_english\": \"Mock Headline: Government Fails to Deliver Promises on Agriculture\",\n \"priority\": \"high\",\n \"category\": \"farmer_issues\",\n \"political_significance\": \"Shows direct failure of government to support farmers leading to massive distress.\",\n \"attack_angle\": \"The ruling party claimed they are pro-farmer, but this report proves they are letting farmers suffer without MSP.\"\n },\n {\n \"id\": \"p001_a003\",\n \"headline_english\": \"Corruption Allegations in Irrigation Project\",\n \"priority\": \"medium\",\n \"category\": \"corruption\",\n \"political_significance\": \"Highlights massive irregularities in the tender process.\",\n \"attack_angle\": \"Demand an immediate CBI probe into the 500-crore scam.\"\n }\n]\n\nprint(\"Generating mock PDF...\")\ngenerate_political_pdf(mock_selected, job_dir)\nprint(\"Done!\")\n"

167
xy_cut.py Normal file
View File

@ -0,0 +1,167 @@
import numpy as np
class Box:
def __init__(self, region):
self.id = region['id']
self.type = region['type']
self.bbox = region['bbox']
self.x1, self.y1, self.x2, self.y2 = self.bbox
self.region = region
def recursive_xy_cut(boxes, min_x_gap=20, min_y_gap=20):
if not boxes:
return []
if len(boxes) == 1:
return [boxes]
# Find bounding box of all boxes
min_x = min(b.x1 for b in boxes)
max_x = max(b.x2 for b in boxes)
min_y = min(b.y1 for b in boxes)
max_y = max(b.y2 for b in boxes)
# Projection profiles
x_profile = np.zeros(max_x - min_x + 1, dtype=int)
y_profile = np.zeros(max_y - min_y + 1, dtype=int)
for b in boxes:
x_profile[max(0, b.x1 - min_x):b.x2 - min_x + 1] = 1
y_profile[max(0, b.y1 - min_y):b.y2 - min_y + 1] = 1
# Find gaps
def find_gaps(profile):
gaps = []
in_gap = profile[0] == 0
start = 0 if in_gap else -1
for i, val in enumerate(profile):
if val == 0 and not in_gap:
in_gap = True
start = i
elif val == 1 and in_gap:
in_gap = False
gaps.append((start, i))
if in_gap:
gaps.append((start, len(profile)))
return gaps
x_gaps = find_gaps(x_profile)
y_gaps = find_gaps(y_profile)
# Filter gaps by threshold
valid_x_gaps = [g for g in x_gaps if g[1] - g[0] >= min_x_gap]
valid_y_gaps = [g for g in y_gaps if g[1] - g[0] >= min_y_gap]
# Choose best cut (prefer Y cuts to separate vertical sections, then X cuts for columns)
# Actually, in newspapers, Y cut (horizontal line) separates main stories or headlines from columns
# X cut (vertical line) separates columns.
# Standard XY cut alternates or picks the widest gap.
best_cut = None
axis = None
if valid_y_gaps:
# Pick the widest Y gap
widest = max(valid_y_gaps, key=lambda g: g[1] - g[0])
best_cut = widest[0] + (widest[1] - widest[0]) // 2 + min_y
axis = 'y'
elif valid_x_gaps:
# Pick the widest X gap
widest = max(valid_x_gaps, key=lambda g: g[1] - g[0])
best_cut = widest[0] + (widest[1] - widest[0]) // 2 + min_x
axis = 'x'
if best_cut is None:
# No cuts can be made, this is a leaf node
return [boxes]
# Split boxes
left_top = []
right_bottom = []
if axis == 'y':
for b in boxes:
if b.y1 + (b.y2 - b.y1)//2 < best_cut:
left_top.append(b)
else:
right_bottom.append(b)
else:
for b in boxes:
if b.x1 + (b.x2 - b.x1)//2 < best_cut:
left_top.append(b)
else:
right_bottom.append(b)
# Recurse
# If a cut didn't actually split anything (due to bounding box overlap centers), stop
if not left_top or not right_bottom:
return [boxes]
return recursive_xy_cut(left_top, min_x_gap, min_y_gap) + recursive_xy_cut(right_bottom, min_x_gap, min_y_gap)
def group_surya_regions_xycut(regions):
"""
Groups regions by using XY-Cut to establish reading order,
then grouping by paragraph_title / doc_title.
"""
if not regions:
return []
boxes = [Box(r) for r in regions]
# Step 1: XY-Cut
leaves = recursive_xy_cut(boxes, min_x_gap=30, min_y_gap=25)
# Step 2: Establish Reading order
# Leaves are already in a roughly top-to-bottom, left-to-right order because
# recursive_xy_cut processes Y cuts then X cuts and appends left_top before right_bottom.
# Flatten boxes in reading order
ordered_boxes = []
for leaf in leaves:
# Sort within leaf just in case
leaf.sort(key=lambda b: (b.y1, b.x1))
ordered_boxes.extend(leaf)
# Step 3: Group into articles
articles = []
current_article = []
current_title = None
for box in ordered_boxes:
# Start a new article if we hit a title
if box.type in ('paragraph_title', 'doc_title'):
# If the current article ONLY contains images, these images likely belong to this new title!
# Or if it contains images at the very end, wait, let's just check if it's ONLY images.
if current_article and all(b.type == 'image' for b in current_article):
current_article.append(box)
else:
if current_article:
articles.append(current_article)
current_article = [box]
else:
current_article.append(box)
if current_article:
articles.append(current_article)
# Format output
output = []
for i, art_boxes in enumerate(articles, start=1):
member_ids = [b.id for b in art_boxes]
# Calculate union bbox
min_x = min(b.x1 for b in art_boxes)
min_y = min(b.y1 for b in art_boxes)
max_x = max(b.x2 for b in art_boxes)
max_y = max(b.y2 for b in art_boxes)
output.append({
"article_id": i,
"title_region": member_ids[0] if art_boxes[0].type in ('paragraph_title', 'doc_title') else None,
"member_regions": member_ids,
"bbox": [min_x, min_y, max_x, max_y],
"dateline": None,
"grouped_by": "xy_cut"
})
return output