205 lines
9.1 KiB
Python
205 lines
9.1 KiB
Python
"""Construct one boundary box per article by fusing three signals:
|
||
|
||
1. DATELINE pattern -> the MINIMUM set of articles (one per '<town>, date:').
|
||
2. HEADLINE above -> the article's top anchor / column span (doc_title).
|
||
3. STRAIGHT RULES -> hard walls: snap top/bottom to horizontal rules and
|
||
left/right to vertical rules between stories.
|
||
|
||
Pure geometry on saved fixtures + the page PNG. No Claude, no API.
|
||
|
||
construct_boundaries(regions, datelines, lines, size) -> list of:
|
||
{region_id, dateline, headline_region, bbox:[l,t,r,b], walls:{...}}
|
||
"""
|
||
|
||
|
||
def _cx(b):
|
||
return (b[0] + b[2]) / 2
|
||
|
||
|
||
def construct_boundaries(regions, datelines, lines, size):
|
||
W, H = size
|
||
rmap = {r["id"]: r for r in regions}
|
||
h_rules = sorted(lines["h"], key=lambda L: L["y1"])
|
||
v_rules = sorted(lines["v"], key=lambda L: L["x1"])
|
||
dls = sorted(datelines, key=lambda s: s["bbox"][1])
|
||
|
||
# ---- duplicate-dateline dedup: PaddleOCR sometimes boxes the SAME physical text
|
||
# twice (and a false dateline match — an ad/phone line — then appears as two
|
||
# near-identical bodies). Two datelines are one article when EITHER their bodies
|
||
# overlap heavily (IoU>0.6) OR one body is mostly nested inside the other AND the
|
||
# dateline text is identical (the same town double-boxed as a thin line + a tall
|
||
# paragraph — IoU stays low but containment is near 1). Keep the larger body and
|
||
# inherit a headline from the dropped twin if the keeper had none.
|
||
def _area(b):
|
||
return (b[2]-b[0]) * (b[3]-b[1])
|
||
|
||
def _same_dateline(sa, sb):
|
||
a, b = sa["bbox"], sb["bbox"]
|
||
ix = max(0, min(a[2], b[2]) - max(a[0], b[0]))
|
||
iy = max(0, min(a[3], b[3]) - max(a[1], b[1]))
|
||
inter = ix * iy
|
||
if not inter:
|
||
return False
|
||
aa, ba = _area(a), _area(b)
|
||
iou = inter / (aa + ba - inter) if (aa + ba - inter) else 0.0
|
||
if iou > 0.6:
|
||
return True
|
||
contain = inter / min(aa, ba) if min(aa, ba) else 0.0
|
||
ta = (sa.get("dateline") or "").strip()
|
||
tb = (sb.get("dateline") or "").strip()
|
||
return contain > 0.8 and ta != "" and ta == tb
|
||
|
||
deduped = []
|
||
for s in dls:
|
||
dup = next((k for k in deduped if _same_dateline(s, k)), None)
|
||
if dup is None:
|
||
deduped.append(s)
|
||
else: # keep larger body; inherit a missing headline
|
||
keep, drop = (s, dup) if _area(s["bbox"]) > _area(dup["bbox"]) else (dup, s)
|
||
if keep.get("headline_region") not in rmap and drop.get("headline_region") in rmap:
|
||
keep = {**keep, "headline_region": drop["headline_region"]}
|
||
deduped[deduped.index(dup)] = keep
|
||
dls = deduped
|
||
|
||
# ---- shared-headline dedup: a doc_title can only head ONE article. When two
|
||
# datelines both matched the same headline (stacked in a column, or a 2-col
|
||
# headline with a missed neighbour), keep it for the dateline with the SMALLEST
|
||
# positive gap below the headline; the others lose it (anchor on their own body).
|
||
by_hl = {}
|
||
for s in dls:
|
||
h = s.get("headline_region")
|
||
if h in rmap:
|
||
by_hl.setdefault(h, []).append(s)
|
||
hid_of = {id(s): s.get("headline_region") for s in dls}
|
||
for h, group in by_hl.items():
|
||
if len(group) <= 1:
|
||
continue
|
||
hb = rmap[h]["bbox"]
|
||
below = [s for s in group if s["bbox"][1] >= hb[3] - 30]
|
||
keeper = min(below, key=lambda s: s["bbox"][1] - hb[3]) if below \
|
||
else min(group, key=lambda s: abs(s["bbox"][1] - hb[3]))
|
||
for s in group:
|
||
if s is not keeper:
|
||
hid_of[id(s)] = None
|
||
|
||
def _anchor_rect(ns):
|
||
"""A neighbour's article ANCHOR = its headline (if it owns one) UNION its
|
||
dateline body. Floors are computed against this rect — not the bare body —
|
||
so a story ends at the TOP of the next story's HEADLINE, and a wide 2-column
|
||
headline that reaches sideways into this column still acts as a wall."""
|
||
b = ns["bbox"]
|
||
h = hid_of.get(id(ns))
|
||
if h in rmap:
|
||
hb = rmap[h]["bbox"]
|
||
return (min(b[0], hb[0]), min(b[1], hb[1]),
|
||
max(b[2], hb[2]), max(b[3], hb[3]))
|
||
return (b[0], b[1], b[2], b[3])
|
||
|
||
def _covers(L, y0, y1):
|
||
# the rule must span the MAJORITY of [y0,y1], not merely touch an edge
|
||
ov = min(L["y2"], y1) - max(L["y1"], y0)
|
||
return ov >= 0.5 * max(1, y1 - y0)
|
||
|
||
def vwall_left(cx, y0, y1):
|
||
cands = [L for L in v_rules if L["x1"] < cx - 20 and _covers(L, y0, y1)]
|
||
return max((L["x1"] for L in cands), default=0)
|
||
|
||
def vwall_right(cx, y0, y1):
|
||
cands = [L for L in v_rules if L["x1"] > cx + 20 and _covers(L, y0, y1)]
|
||
return min((L["x1"] for L in cands), default=W)
|
||
|
||
def hrule_between(x0, x1, y_lo, y_hi, want_above):
|
||
"""Return the horizontal rule whose span covers [x0,x1] within (y_lo,y_hi).
|
||
want_above=True -> the LOWEST such rule (closest above the body's top is the
|
||
bottom-most of those above); we pick nearest to the reference edge."""
|
||
hits = [L for L in h_rules
|
||
if L["y1"] > y_lo and L["y1"] < y_hi
|
||
and L["x1"] <= x1 - 30 and L["x2"] >= x0 + 30]
|
||
if not hits:
|
||
return None
|
||
# nearest to the reference edge: above -> max y (closest below ceiling ref),
|
||
# below -> min y (closest above floor ref)
|
||
return max(hits, key=lambda L: L["y1"]) if want_above \
|
||
else min(hits, key=lambda L: L["y1"])
|
||
|
||
out = []
|
||
for i, s in enumerate(dls):
|
||
db = s["bbox"]
|
||
cx = _cx(db)
|
||
hid = hid_of[id(s)]
|
||
hb = rmap[hid]["bbox"] if (hid in rmap) else None
|
||
|
||
# ---- horizontal span: UNION of headline + dateline body so the body is
|
||
# always contained; top anchor is the headline top if present.
|
||
if hb:
|
||
left0, right0 = min(hb[0], db[0]), max(hb[2], db[2])
|
||
top_anchor = hb[1]
|
||
else:
|
||
left0, right0 = db[0], db[2]
|
||
top_anchor = db[1]
|
||
|
||
# ---- CEILING: snap up to a horizontal rule just above the anchor (but no
|
||
# higher than the previous article's dateline in this column).
|
||
prev_y = 0
|
||
for ps in dls:
|
||
if ps is s:
|
||
continue
|
||
pb = ps["bbox"]
|
||
if pb[0] - 40 <= cx <= pb[2] + 40 and pb[3] <= top_anchor:
|
||
prev_y = max(prev_y, pb[3])
|
||
ceil_rule = hrule_between(left0, right0, prev_y, top_anchor + 20, want_above=True)
|
||
# a ceiling rule only counts if it sits a sane distance ABOVE the anchor
|
||
# (a far-away rule is a different story's divider, not this article's top).
|
||
top = top_anchor
|
||
if ceil_rule and 0 <= (top_anchor - ceil_rule["y1"]) <= 500:
|
||
top = ceil_rule["y1"]
|
||
else:
|
||
ceil_rule = None
|
||
top = min(top, top_anchor) # never cut INTO the article from above
|
||
|
||
# ---- FLOOR: nearest boundary directly below in THIS dateline's OWN column
|
||
# (own column, not the wide headline span — a neighbour under a 2-col
|
||
# headline must clip the SIDE, not act as the floor).
|
||
own_l, own_r = db[0], db[2]
|
||
next_y = H
|
||
for ns in dls:
|
||
if ns is s:
|
||
continue
|
||
na = _anchor_rect(ns) # headline ∪ body of neighbour
|
||
if na[1] > db[1] + 30 and not (na[2] < own_l + 20 or na[0] > own_r - 20):
|
||
next_y = min(next_y, na[1])
|
||
floor_rule = hrule_between(own_l, own_r, db[3], next_y + 5, want_above=False)
|
||
bottom = floor_rule["y1"] if floor_rule else next_y
|
||
bottom = max(bottom, db[3]) # never cut INTO the article from below
|
||
|
||
# ---- SIDE-CLIP: a neighbour dateline that sits beside this body within the
|
||
# (wide) headline span clips the corresponding edge, so a left-column
|
||
# story never swallows the right column's separate story.
|
||
for ns in dls:
|
||
if ns is s:
|
||
continue
|
||
nb = ns["bbox"]
|
||
if nb[3] > top_anchor + 20 and nb[1] < bottom - 20: # vertical overlap
|
||
if own_r - 10 <= nb[0] < right0: # neighbour to the right
|
||
right0 = min(right0, nb[0])
|
||
if left0 < nb[2] <= own_l + 10: # neighbour to the left
|
||
left0 = max(left0, nb[2])
|
||
|
||
# ---- side walls: snap to vertical rules spanning the article's y-band
|
||
lwall = vwall_left(cx, top, bottom)
|
||
rwall = vwall_right(cx, top, bottom)
|
||
left = max(left0 - 20, lwall)
|
||
right = min(right0 + 20, rwall)
|
||
left = min(left, db[0]) # guarantee the dateline body fits
|
||
right = max(right, db[2])
|
||
|
||
out.append({
|
||
"region_id": s["region_id"],
|
||
"dateline": s.get("dateline", ""),
|
||
"headline_region": hid,
|
||
"bbox": [int(left), int(top), int(right), int(bottom)],
|
||
"walls": {"ceil_rule": bool(ceil_rule), "floor_rule": bool(floor_rule),
|
||
"lwall": int(lwall), "rwall": int(rwall)},
|
||
})
|
||
return out
|