Ellipses
This commit is contained in:
BIN
002-page.jpg
Executable file
BIN
002-page.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
BIN
bubble-detection.jpg
Executable file
BIN
bubble-detection.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 362 KiB |
1508
bubbles.json
1508
bubbles.json
File diff suppressed because it is too large
Load Diff
1037
manga-renderer.py
1037
manga-renderer.py
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ import cv2
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import easyocr
|
import easyocr
|
||||||
from deep_translator import GoogleTranslator
|
from deep_translator import GoogleTranslator
|
||||||
from sklearn.cluster import DBSCAN
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -38,7 +37,7 @@ SOUND_EFFECT_PATTERNS = [
|
|||||||
r"^oh+$", r"^ugh+$", r"^gr+$", r"^bam+$",
|
r"^oh+$", r"^ugh+$", r"^gr+$", r"^bam+$",
|
||||||
r"^pow+$", r"^crash+$", r"^boom+$", r"^bang+$",
|
r"^pow+$", r"^crash+$", r"^boom+$", r"^bang+$",
|
||||||
r"^crack+$", r"^whoosh+$", r"^thud+$", r"^snap+$",
|
r"^crack+$", r"^whoosh+$", r"^thud+$", r"^snap+$",
|
||||||
r"^zip+$", r"^swoosh+$",
|
r"^zip+$", r"^swoosh+$", r"^chirp+$", r"^tweet+$",
|
||||||
]
|
]
|
||||||
|
|
||||||
def is_sound_effect(text):
|
def is_sound_effect(text):
|
||||||
@@ -47,6 +46,39 @@ def is_sound_effect(text):
|
|||||||
for p in SOUND_EFFECT_PATTERNS)
|
for p in SOUND_EFFECT_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# TITLE / LOGO / AUTHOR FILTER
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
TITLE_PATTERNS = [
|
||||||
|
r"^(mission|chapter|episode|vol\.?|volume)\s*\d+$",
|
||||||
|
r"^(spy|family|spy.family)$",
|
||||||
|
r"^by\s+.+$", # "BY TATSUYA ENDO"
|
||||||
|
r"^[a-z]{1,4}\s+[a-z]+\s+[a-z]+$", # short author-style lines
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_title_text(text):
|
||||||
|
cleaned = text.strip().lower()
|
||||||
|
return any(re.fullmatch(p, cleaned, re.IGNORECASE)
|
||||||
|
for p in TITLE_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# GARBAGE TOKEN FILTER
|
||||||
|
# Catches OCR misreads that are mostly
|
||||||
|
# non-alpha or suspiciously short/mangled
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
GARBAGE_PATTERNS = [
|
||||||
|
r"^[^a-zA-Z]*$", # no letters at all
|
||||||
|
r"^.{1,2}$", # 1-2 char tokens
|
||||||
|
r".*\d+.*", # contains digits (YO4, HLNGRY etc.)
|
||||||
|
r"^[A-Z]{1,4}$", # isolated caps abbreviations (IILK)
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_garbage(text):
|
||||||
|
t = text.strip()
|
||||||
|
return any(re.fullmatch(p, t) for p in GARBAGE_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# TOKEN CLASSIFIER
|
# TOKEN CLASSIFIER
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -54,15 +86,6 @@ def classify_token(text, confidence, confidence_threshold,
|
|||||||
min_text_length, filter_sound_effects):
|
min_text_length, filter_sound_effects):
|
||||||
"""
|
"""
|
||||||
Returns one of: "alpha" | "punct" | "noise"
|
Returns one of: "alpha" | "punct" | "noise"
|
||||||
|
|
||||||
Rules (in order):
|
|
||||||
1. confidence below threshold → noise
|
|
||||||
2. shorter than min_text_length → noise
|
|
||||||
3. pure digit string → noise
|
|
||||||
4. single non-alpha character → noise
|
|
||||||
5. sound effect (if filter enabled) → noise
|
|
||||||
6. 2+ chars with no letters → punct
|
|
||||||
7. has at least one letter → alpha
|
|
||||||
"""
|
"""
|
||||||
cleaned = text.strip()
|
cleaned = text.strip()
|
||||||
|
|
||||||
@@ -76,90 +99,61 @@ def classify_token(text, confidence, confidence_threshold,
|
|||||||
return "noise"
|
return "noise"
|
||||||
if filter_sound_effects and is_sound_effect(cleaned):
|
if filter_sound_effects and is_sound_effect(cleaned):
|
||||||
return "noise"
|
return "noise"
|
||||||
|
if is_title_text(cleaned):
|
||||||
|
return "noise"
|
||||||
|
if is_garbage(cleaned):
|
||||||
|
return "noise"
|
||||||
if not any(ch.isalpha() for ch in cleaned):
|
if not any(ch.isalpha() for ch in cleaned):
|
||||||
return "punct"
|
return "punct"
|
||||||
|
|
||||||
return "alpha"
|
return "alpha"
|
||||||
|
|
||||||
|
|
||||||
def should_keep_token(text, confidence, confidence_threshold,
|
def should_keep_token(text, confidence, confidence_threshold,
|
||||||
min_text_length, filter_sound_effects):
|
min_text_length, filter_sound_effects):
|
||||||
"""
|
|
||||||
Backward-compatible wrapper.
|
|
||||||
Returns (keep: bool, category: str).
|
|
||||||
"""
|
|
||||||
cat = classify_token(text, confidence, confidence_threshold,
|
cat = classify_token(text, confidence, confidence_threshold,
|
||||||
min_text_length, filter_sound_effects)
|
min_text_length, filter_sound_effects)
|
||||||
return cat != "noise", cat
|
return cat != "noise", cat
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# BOUNDING BOX
|
# QUAD HELPERS
|
||||||
#
|
|
||||||
# Flat union of ALL quad corners.
|
|
||||||
# Handles every layout correctly:
|
|
||||||
# • "HN" + "..." same line → horizontal union
|
|
||||||
# • Multi-line bubbles → vertical union
|
|
||||||
# • Rotated/skewed quads → all 4 corners included
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
def get_cluster_bbox_from_ocr(ocr_bboxes, image_shape,
|
def quad_bbox(quad):
|
||||||
padding_px=10):
|
xs = [pt[0] for pt in quad]
|
||||||
"""
|
ys = [pt[1] for pt in quad]
|
||||||
Computes the bubble erase bbox by taking the flat union
|
return min(xs), min(ys), max(xs), max(ys)
|
||||||
of ALL quad corners.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ocr_bboxes : List of EasyOCR quad bboxes
|
|
||||||
Each = [[x0,y0],[x1,y1],[x2,y2],[x3,y3]]
|
|
||||||
image_shape : (height, width) for clamping
|
|
||||||
padding_px : Expansion on each side (default: 10)
|
|
||||||
|
|
||||||
Returns:
|
def quads_bbox(quads, image_shape, padding_px=10):
|
||||||
(x1, y1, x2, y2) clamped to image bounds
|
|
||||||
"""
|
|
||||||
img_h, img_w = image_shape[:2]
|
img_h, img_w = image_shape[:2]
|
||||||
|
all_x = [pt[0] for quad in quads for pt in quad]
|
||||||
if not ocr_bboxes:
|
all_y = [pt[1] for quad in quads for pt in quad]
|
||||||
return 0, 0, 0, 0
|
|
||||||
|
|
||||||
all_x = [pt[0] for quad in ocr_bboxes for pt in quad]
|
|
||||||
all_y = [pt[1] for quad in ocr_bboxes for pt in quad]
|
|
||||||
|
|
||||||
x1 = max(0, min(all_x) - padding_px)
|
x1 = max(0, min(all_x) - padding_px)
|
||||||
y1 = max(0, min(all_y) - padding_px)
|
y1 = max(0, min(all_y) - padding_px)
|
||||||
x2 = min(img_w, max(all_x) + padding_px)
|
x2 = min(img_w, max(all_x) + padding_px)
|
||||||
y2 = min(img_h, max(all_y) + padding_px)
|
y2 = min(img_h, max(all_y) + padding_px)
|
||||||
|
|
||||||
return x1, y1, x2, y2
|
return x1, y1, x2, y2
|
||||||
|
|
||||||
|
|
||||||
def get_cluster_bbox(items):
|
def bboxes_overlap_or_touch(a, b, gap_px=0):
|
||||||
"""Fallback center-point bbox — used only during merge step."""
|
ax1, ay1, ax2, ay2 = a
|
||||||
half = 30
|
bx1, by1, bx2, by2 = b
|
||||||
x1 = min(cx for _, cx, _ in items) - half
|
gap_x = max(0, max(ax1, bx1) - min(ax2, bx2))
|
||||||
y1 = min(cy for cy, _, _ in items) - half
|
gap_y = max(0, max(ay1, by1) - min(ay2, by2))
|
||||||
x2 = max(cx for _, cx, _ in items) + half
|
return gap_x <= gap_px and gap_y <= gap_px
|
||||||
y2 = max(cy for cy, _, _ in items) + half
|
|
||||||
return x1, y1, x2, y2
|
|
||||||
|
|
||||||
|
|
||||||
def boxes_are_close(bbox_a, bbox_b, proximity_px=80):
|
|
||||||
ax1, ay1, ax2, ay2 = bbox_a
|
|
||||||
bx1, by1, bx2, by2 = bbox_b
|
|
||||||
ax1 -= proximity_px; ay1 -= proximity_px
|
|
||||||
ax2 += proximity_px; ay2 += proximity_px
|
|
||||||
return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# POST-CLUSTER MERGE (Union-Find)
|
# OVERLAP-BASED GROUPING (Union-Find)
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
def merge_nearby_clusters(raw_clusters, raw_quads,
|
def group_quads_by_overlap(ocr_results, image_shape,
|
||||||
proximity_px=80):
|
gap_px=18, bbox_padding=10):
|
||||||
labels = list(raw_clusters.keys())
|
n = len(ocr_results)
|
||||||
bboxes = {lbl: get_cluster_bbox(raw_clusters[lbl])
|
if n == 0:
|
||||||
for lbl in labels}
|
return {}, {}, {}
|
||||||
parent = {lbl: lbl for lbl in labels}
|
|
||||||
|
token_bboxes = [quad_bbox(r[0]) for r in ocr_results]
|
||||||
|
parent = list(range(n))
|
||||||
|
|
||||||
def find(x):
|
def find(x):
|
||||||
while parent[x] != x:
|
while parent[x] != x:
|
||||||
@@ -170,32 +164,95 @@ def merge_nearby_clusters(raw_clusters, raw_quads,
|
|||||||
def union(x, y):
|
def union(x, y):
|
||||||
parent[find(x)] = find(y)
|
parent[find(x)] = find(y)
|
||||||
|
|
||||||
for i in range(len(labels)):
|
for i in range(n):
|
||||||
for j in range(i + 1, len(labels)):
|
for j in range(i + 1, n):
|
||||||
a, b = labels[i], labels[j]
|
if bboxes_overlap_or_touch(
|
||||||
if boxes_are_close(bboxes[a], bboxes[b], proximity_px):
|
token_bboxes[i], token_bboxes[j],
|
||||||
union(a, b)
|
gap_px=gap_px):
|
||||||
|
union(i, j)
|
||||||
|
|
||||||
merged_clusters = {}
|
groups = {}
|
||||||
merged_quads = {}
|
for i in range(n):
|
||||||
for lbl in labels:
|
root = find(i)
|
||||||
root = find(lbl)
|
groups.setdefault(root, [])
|
||||||
merged_clusters.setdefault(root, [])
|
groups[root].append(i)
|
||||||
merged_quads.setdefault(root, [])
|
|
||||||
merged_clusters[root].extend(raw_clusters[lbl])
|
|
||||||
merged_quads[root].extend(raw_quads[lbl])
|
|
||||||
|
|
||||||
return merged_clusters, merged_quads
|
def group_sort_key(indices):
|
||||||
|
ys = [token_bboxes[i][1] for i in indices]
|
||||||
|
xs = [token_bboxes[i][0] for i in indices]
|
||||||
|
return (min(ys) // 150, min(xs))
|
||||||
|
|
||||||
|
sorted_groups = sorted(groups.values(), key=group_sort_key)
|
||||||
|
|
||||||
|
bubble_dict = {}
|
||||||
|
bbox_dict = {}
|
||||||
|
ocr_quads = {}
|
||||||
|
|
||||||
|
for gid, indices in enumerate(sorted_groups, start=1):
|
||||||
|
indices_sorted = sorted(
|
||||||
|
indices, key=lambda i: token_bboxes[i][1])
|
||||||
|
|
||||||
|
quads = [ocr_results[i][0] for i in indices_sorted]
|
||||||
|
raw_texts = [ocr_results[i][1] for i in indices_sorted]
|
||||||
|
|
||||||
|
alpha_lines = []
|
||||||
|
punct_tokens = []
|
||||||
|
|
||||||
|
for i in indices_sorted:
|
||||||
|
_, text, _ = ocr_results[i]
|
||||||
|
yc = (token_bboxes[i][1] + token_bboxes[i][3]) / 2.0
|
||||||
|
if any(ch.isalpha() for ch in text):
|
||||||
|
alpha_lines.append((yc, text))
|
||||||
|
else:
|
||||||
|
punct_tokens.append((yc, text))
|
||||||
|
|
||||||
|
for pcy, ptext in punct_tokens:
|
||||||
|
if alpha_lines:
|
||||||
|
closest = min(
|
||||||
|
range(len(alpha_lines)),
|
||||||
|
key=lambda k: abs(alpha_lines[k][0] - pcy)
|
||||||
|
)
|
||||||
|
yc_a, text_a = alpha_lines[closest]
|
||||||
|
alpha_lines[closest] = (yc_a, text_a + ptext)
|
||||||
|
|
||||||
|
text_lines = [t for _, t in alpha_lines] or raw_texts
|
||||||
|
|
||||||
|
bubble_dict[gid] = text_lines
|
||||||
|
ocr_quads[gid] = quads
|
||||||
|
bbox_dict[gid] = quads_bbox(quads, image_shape,
|
||||||
|
padding_px=bbox_padding)
|
||||||
|
|
||||||
|
b = bbox_dict[gid]
|
||||||
|
print(f" Group #{gid}: {len(quads)} quad(s) "
|
||||||
|
f"bbox=({int(b[0])},{int(b[1])})→"
|
||||||
|
f"({int(b[2])},{int(b[3])}) "
|
||||||
|
f"w={int(b[2]-b[0])} h={int(b[3]-b[1])} "
|
||||||
|
f"text={text_lines}")
|
||||||
|
|
||||||
|
return bubble_dict, bbox_dict, ocr_quads
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# HYPHEN REMOVAL
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def fix_hyphens(lines):
|
||||||
|
if not lines:
|
||||||
|
return ""
|
||||||
|
merged = lines[0]
|
||||||
|
for line in lines[1:]:
|
||||||
|
line = line.strip()
|
||||||
|
merged = (merged[:-1] + line if merged.endswith("-")
|
||||||
|
else merged + " " + line)
|
||||||
|
return re.sub(r" {2,}", " ", merged).strip().upper()
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# CROP-BASED OCR RE-READ
|
# CROP-BASED OCR RE-READ
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
def reread_cluster_crop(image, bbox, reader, source_lang,
|
def reread_cluster_crop(image, bbox, reader,
|
||||||
padding_px=20, upscale_factor=2.5):
|
padding_px=20, upscale_factor=2.5):
|
||||||
img_h, img_w = image.shape[:2]
|
img_h, img_w = image.shape[:2]
|
||||||
x1, y1, x2, y2 = bbox
|
x1, y1, x2, y2 = bbox
|
||||||
|
|
||||||
x1 = max(0, int(x1) - padding_px)
|
x1 = max(0, int(x1) - padding_px)
|
||||||
y1 = max(0, int(y1) - padding_px)
|
y1 = max(0, int(y1) - padding_px)
|
||||||
x2 = min(img_w, int(x2) + padding_px)
|
x2 = min(img_w, int(x2) + padding_px)
|
||||||
@@ -224,164 +281,22 @@ def reread_cluster_crop(image, bbox, reader, source_lang,
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
crop_results.sort(key=lambda r: r[0][0][1])
|
crop_results.sort(key=lambda r: r[0][0][1])
|
||||||
lines = [t.strip() for _, t, _ in crop_results if t.strip()]
|
lines = [t.strip().upper() for _, t, _ in crop_results
|
||||||
|
if t.strip()]
|
||||||
return fix_hyphens(lines) if lines else None
|
return fix_hyphens(lines) if lines else None
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# DBSCAN BUBBLE CLUSTERING
|
# AUTO GAP
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
def cluster_into_bubbles(ocr_results, image_shape,
|
def compute_auto_gap(image_path, base_gap=18,
|
||||||
eps=80, min_samples=1,
|
|
||||||
proximity_px=80, bbox_padding=10):
|
|
||||||
"""
|
|
||||||
Two-pass clustering:
|
|
||||||
Pass 1 — DBSCAN on center points
|
|
||||||
Pass 2 — Bounding-box proximity merge
|
|
||||||
|
|
||||||
Token handling per cluster:
|
|
||||||
"alpha" tokens → translation text + bbox
|
|
||||||
"punct" tokens → bbox included, appended to nearest
|
|
||||||
alpha line by Y distance
|
|
||||||
(e.g. "..." joins "HN" → "HN...")
|
|
||||||
|
|
||||||
Bbox uses flat union of ALL quad corners:
|
|
||||||
min/max of all x,y across every quad in the cluster.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bubble_dict : cluster_id → list of text lines
|
|
||||||
bbox_dict : cluster_id → (x1, y1, x2, y2)
|
|
||||||
ocr_quads : cluster_id → list of ALL raw quads
|
|
||||||
"""
|
|
||||||
if not ocr_results:
|
|
||||||
return {}, {}, {}
|
|
||||||
|
|
||||||
centers = []
|
|
||||||
for bbox, text, confidence in ocr_results:
|
|
||||||
xs = [pt[0] for pt in bbox]
|
|
||||||
ys = [pt[1] for pt in bbox]
|
|
||||||
centers.append([sum(xs) / 4, sum(ys) / 4])
|
|
||||||
|
|
||||||
centers_array = np.array(centers, dtype=np.float32)
|
|
||||||
db = DBSCAN(eps=eps, min_samples=min_samples,
|
|
||||||
metric="euclidean")
|
|
||||||
labels = db.fit_predict(centers_array)
|
|
||||||
|
|
||||||
raw_clusters = {}
|
|
||||||
raw_quads = {}
|
|
||||||
noise_counter = int(max(labels, default=0)) + 1
|
|
||||||
|
|
||||||
for idx, label in enumerate(labels):
|
|
||||||
if label == -1:
|
|
||||||
label = noise_counter
|
|
||||||
noise_counter += 1
|
|
||||||
raw_clusters.setdefault(label, [])
|
|
||||||
raw_quads.setdefault(label, [])
|
|
||||||
bbox, text, _ = ocr_results[idx]
|
|
||||||
raw_clusters[label].append(
|
|
||||||
(centers[idx][1], centers[idx][0], text))
|
|
||||||
raw_quads[label].append(bbox)
|
|
||||||
|
|
||||||
print(f" DBSCAN pass: {len(raw_clusters)} cluster(s)")
|
|
||||||
|
|
||||||
merged_clusters, merged_quads = merge_nearby_clusters(
|
|
||||||
raw_clusters, raw_quads, proximity_px=proximity_px
|
|
||||||
)
|
|
||||||
print(f" After merge: {len(merged_clusters)} cluster(s)")
|
|
||||||
|
|
||||||
row_band_px = 150
|
|
||||||
|
|
||||||
def cluster_sort_key(items):
|
|
||||||
return (min(cy for cy, cx, _ in items) // row_band_px,
|
|
||||||
min(cx for cy, cx, _ in items))
|
|
||||||
|
|
||||||
sorted_labels = sorted(
|
|
||||||
merged_clusters.keys(),
|
|
||||||
key=lambda lbl: cluster_sort_key(merged_clusters[lbl])
|
|
||||||
)
|
|
||||||
|
|
||||||
bubble_dict = {}
|
|
||||||
bbox_dict = {}
|
|
||||||
ocr_quads = {}
|
|
||||||
|
|
||||||
for i, lbl in enumerate(sorted_labels, start=1):
|
|
||||||
items = merged_clusters[lbl]
|
|
||||||
quads = merged_quads[lbl]
|
|
||||||
|
|
||||||
items_sorted = sorted(items, key=lambda t: t[0])
|
|
||||||
|
|
||||||
# ── Separate alpha and punct tokens ───────────────────────
|
|
||||||
alpha_lines = [] # (cy, text)
|
|
||||||
punct_tokens = [] # (cy, text)
|
|
||||||
|
|
||||||
for cy, cx, text in items_sorted:
|
|
||||||
if any(ch.isalpha() for ch in text):
|
|
||||||
alpha_lines.append((cy, text))
|
|
||||||
else:
|
|
||||||
punct_tokens.append((cy, text))
|
|
||||||
|
|
||||||
# ── Append punct to closest alpha line by Y ───────────────
|
|
||||||
for pcy, ptext in punct_tokens:
|
|
||||||
if alpha_lines:
|
|
||||||
closest_idx = min(
|
|
||||||
range(len(alpha_lines)),
|
|
||||||
key=lambda k: abs(alpha_lines[k][0] - pcy)
|
|
||||||
)
|
|
||||||
cy_a, text_a = alpha_lines[closest_idx]
|
|
||||||
alpha_lines[closest_idx] = (cy_a, text_a + ptext)
|
|
||||||
|
|
||||||
text_lines = [t for _, t in alpha_lines]
|
|
||||||
|
|
||||||
# Fallback: no alpha at all → keep everything as-is
|
|
||||||
if not text_lines:
|
|
||||||
text_lines = [text for _, _, text in items_sorted]
|
|
||||||
|
|
||||||
bubble_dict[i] = text_lines
|
|
||||||
ocr_quads[i] = quads # ALL quads → full bbox coverage
|
|
||||||
|
|
||||||
bbox_dict[i] = get_cluster_bbox_from_ocr(
|
|
||||||
quads, image_shape, padding_px=bbox_padding
|
|
||||||
)
|
|
||||||
|
|
||||||
b = bbox_dict[i]
|
|
||||||
print(f" Cluster #{i}: {len(quads)} quad(s) "
|
|
||||||
f"bbox=({int(b[0])},{int(b[1])})→"
|
|
||||||
f"({int(b[2])},{int(b[3])}) "
|
|
||||||
f"w={int(b[2]-b[0])} h={int(b[3]-b[1])} "
|
|
||||||
f"text={text_lines}")
|
|
||||||
|
|
||||||
return bubble_dict, bbox_dict, ocr_quads
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# HYPHEN REMOVAL
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
def fix_hyphens(lines):
|
|
||||||
"""
|
|
||||||
Joins lines, merging mid-word hyphens.
|
|
||||||
e.g. ["GRAVEMEN-", "TE"] → "GRAVEMENTE"
|
|
||||||
"""
|
|
||||||
if not lines:
|
|
||||||
return ""
|
|
||||||
merged = lines[0]
|
|
||||||
for line in lines[1:]:
|
|
||||||
line = line.strip()
|
|
||||||
merged = (merged[:-1] + line if merged.endswith("-")
|
|
||||||
else merged + " " + line)
|
|
||||||
return re.sub(r" {2,}", " ", merged).strip()
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# AUTO EPS
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
def compute_auto_eps(image_path, base_eps=80,
|
|
||||||
reference_width=750):
|
reference_width=750):
|
||||||
image = cv2.imread(image_path)
|
image = cv2.imread(image_path)
|
||||||
if image is None:
|
if image is None:
|
||||||
return base_eps
|
return base_gap
|
||||||
img_w = image.shape[1]
|
img_w = image.shape[1]
|
||||||
scaled = base_eps * (img_w / reference_width)
|
scaled = base_gap * (img_w / reference_width)
|
||||||
print(f" ℹ️ Image width: {img_w}px → auto eps: {scaled:.1f}px")
|
print(f" ℹ️ Image width: {img_w}px → auto gap: {scaled:.1f}px")
|
||||||
return scaled
|
return scaled
|
||||||
|
|
||||||
|
|
||||||
@@ -400,17 +315,56 @@ def ocr_quality_score(text):
|
|||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# BUBBLE JSON EXPORT
|
# BUBBLE JSON EXPORT
|
||||||
|
# bbox_expand_ratio: grow bbox by this fraction
|
||||||
|
# of its own size in each direction to better
|
||||||
|
# approximate the full speech bubble boundary.
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
def export_bubble_boxes(bbox_dict, ocr_quads_dict,
|
def export_bubble_boxes(bbox_dict, ocr_quads_dict,
|
||||||
filepath="bubbles.json"):
|
filepath="bubbles.json",
|
||||||
|
bbox_expand_ratio=0.35,
|
||||||
|
image_shape=None):
|
||||||
export = {}
|
export = {}
|
||||||
for bubble_id, (x1, y1, x2, y2) in bbox_dict.items():
|
for bubble_id, (x1, y1, x2, y2) in bbox_dict.items():
|
||||||
quads = ocr_quads_dict.get(bubble_id, [])
|
quads = ocr_quads_dict.get(bubble_id, [])
|
||||||
|
|
||||||
|
# ── Expand bbox to approximate full bubble ────────────────
|
||||||
|
w_orig = x2 - x1
|
||||||
|
h_orig = y2 - y1
|
||||||
|
pad_x = int(w_orig * bbox_expand_ratio)
|
||||||
|
pad_y = int(h_orig * bbox_expand_ratio)
|
||||||
|
|
||||||
|
# Clamp to image bounds if image_shape provided
|
||||||
|
if image_shape is not None:
|
||||||
|
img_h, img_w = image_shape[:2]
|
||||||
|
ex1 = max(0, x1 - pad_x)
|
||||||
|
ey1 = max(0, y1 - pad_y)
|
||||||
|
ex2 = min(img_w, x2 + pad_x)
|
||||||
|
ey2 = min(img_h, y2 + pad_y)
|
||||||
|
else:
|
||||||
|
ex1 = x1 - pad_x
|
||||||
|
ey1 = y1 - pad_y
|
||||||
|
ex2 = x2 + pad_x
|
||||||
|
ey2 = y2 + pad_y
|
||||||
|
|
||||||
export[str(bubble_id)] = {
|
export[str(bubble_id)] = {
|
||||||
"x" : int(x1),
|
"x" : int(ex1),
|
||||||
"y" : int(y1),
|
"y" : int(ey1),
|
||||||
"w" : int(x2 - x1),
|
"w" : int(ex2 - ex1),
|
||||||
"h" : int(y2 - y1),
|
"h" : int(ey2 - ey1),
|
||||||
|
# Original tight bbox kept for reference
|
||||||
|
"x_tight" : int(x1),
|
||||||
|
"y_tight" : int(y1),
|
||||||
|
"w_tight" : int(w_orig),
|
||||||
|
"h_tight" : int(h_orig),
|
||||||
|
"quad_bboxes" : [
|
||||||
|
{
|
||||||
|
"x": int(quad_bbox(q)[0]),
|
||||||
|
"y": int(quad_bbox(q)[1]),
|
||||||
|
"w": int(quad_bbox(q)[2] - quad_bbox(q)[0]),
|
||||||
|
"h": int(quad_bbox(q)[3] - quad_bbox(q)[1]),
|
||||||
|
}
|
||||||
|
for q in quads
|
||||||
|
],
|
||||||
"quads": [[[int(pt[0]), int(pt[1])] for pt in quad]
|
"quads": [[[int(pt[0]), int(pt[1])] for pt in quad]
|
||||||
for quad in quads],
|
for quad in quads],
|
||||||
}
|
}
|
||||||
@@ -420,13 +374,24 @@ def export_bubble_boxes(bbox_dict, ocr_quads_dict,
|
|||||||
|
|
||||||
print(f"\n📦 Bubble boxes saved → {filepath}")
|
print(f"\n📦 Bubble boxes saved → {filepath}")
|
||||||
for bid, v in export.items():
|
for bid, v in export.items():
|
||||||
print(f" #{bid}: ({v['x']},{v['y']}) "
|
print(f" #{bid}: expanded=({v['x']},{v['y']}) "
|
||||||
f"{v['w']}×{v['h']}px "
|
f"{v['w']}×{v['h']}px "
|
||||||
|
f"tight={v['w_tight']}×{v['h_tight']}px "
|
||||||
f"[{len(v['quads'])} quad(s)]")
|
f"[{len(v['quads'])} quad(s)]")
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# OUTPUT.TXT WRITER
|
||||||
|
# Uses a pipe | as unambiguous delimiter
|
||||||
|
# Format: #ID|ORIGINAL|TRANSLATED
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def write_output(output_lines, filepath):
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(output_lines))
|
||||||
|
print(f"📄 Translations saved → {filepath}")
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# DEBUG CLUSTER IMAGE
|
# DEBUG IMAGE
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
def save_debug_clusters(image_path, ocr_results,
|
def save_debug_clusters(image_path, ocr_results,
|
||||||
bubble_dict, bbox_dict):
|
bubble_dict, bbox_dict):
|
||||||
@@ -474,26 +439,24 @@ def save_debug_clusters(image_path, ocr_results,
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
def translate_manga_text(
|
def translate_manga_text(
|
||||||
image_path,
|
image_path,
|
||||||
source_lang="it",
|
source_lang="en",
|
||||||
target_lang="ca",
|
target_lang="ca",
|
||||||
confidence_threshold=0.10,
|
confidence_threshold=0.10,
|
||||||
export_to_file=None,
|
export_to_file=None,
|
||||||
export_bubbles_to="bubbles.json",
|
export_bubbles_to="bubbles.json",
|
||||||
min_text_length=2,
|
min_text_length=2,
|
||||||
cluster_eps="auto",
|
gap_px="auto",
|
||||||
proximity_px=80,
|
|
||||||
filter_sound_effects=True,
|
filter_sound_effects=True,
|
||||||
quality_threshold=0.5,
|
quality_threshold=0.5,
|
||||||
upscale_factor=2.5,
|
upscale_factor=2.5,
|
||||||
bbox_padding=10,
|
bbox_padding=10,
|
||||||
debug=False,
|
debug=False,
|
||||||
):
|
):
|
||||||
# ── 1. Resolve eps ────────────────────────────────────────────
|
# ── 1. Resolve gap ────────────────────────────────────────────
|
||||||
if cluster_eps == "auto":
|
if gap_px == "auto":
|
||||||
print("Computing auto eps...")
|
resolved_gap = compute_auto_gap(image_path)
|
||||||
eps = compute_auto_eps(image_path)
|
|
||||||
else:
|
else:
|
||||||
eps = float(cluster_eps)
|
resolved_gap = float(gap_px)
|
||||||
|
|
||||||
# ── 2. Load full image ────────────────────────────────────────
|
# ── 2. Load full image ────────────────────────────────────────
|
||||||
full_image = cv2.imread(image_path)
|
full_image = cv2.imread(image_path)
|
||||||
@@ -521,7 +484,7 @@ def translate_manga_text(
|
|||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
||||||
for bbox, text, confidence in results:
|
for bbox, text, confidence in results:
|
||||||
cleaned = text.strip()
|
cleaned = text.strip().upper()
|
||||||
keep, category = should_keep_token(
|
keep, category = should_keep_token(
|
||||||
cleaned, confidence,
|
cleaned, confidence,
|
||||||
confidence_threshold, min_text_length,
|
confidence_threshold, min_text_length,
|
||||||
@@ -530,10 +493,13 @@ def translate_manga_text(
|
|||||||
if keep:
|
if keep:
|
||||||
filtered.append((bbox, cleaned, confidence))
|
filtered.append((bbox, cleaned, confidence))
|
||||||
if category == "punct":
|
if category == "punct":
|
||||||
print(f" ✔ Punct kept: '{cleaned}'")
|
print(f" ✔ Punct kept: '{cleaned}'")
|
||||||
else:
|
else:
|
||||||
if is_sound_effect(cleaned):
|
tag = ("🔇 SFX" if is_sound_effect(cleaned) else
|
||||||
print(f" 🔇 SFX skipped: '{cleaned}'")
|
"🏷 Title" if is_title_text(cleaned) else
|
||||||
|
"🗑 Garbage" if is_garbage(cleaned) else
|
||||||
|
"✂️ Low-conf")
|
||||||
|
print(f" {tag} skipped: '{cleaned}'")
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
|
||||||
print(f" ✅ {len(filtered)} kept, {skipped} skipped.\n")
|
print(f" ✅ {len(filtered)} kept, {skipped} skipped.\n")
|
||||||
@@ -542,21 +508,20 @@ def translate_manga_text(
|
|||||||
print("⚠️ No text detected after filtering.")
|
print("⚠️ No text detected after filtering.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── 7. Cluster + merge ────────────────────────────────────────
|
# ── 7. Group by overlap ───────────────────────────────────────
|
||||||
print(f"Clustering (eps={eps:.1f}px, "
|
print(f"Grouping by overlap "
|
||||||
f"proximity={proximity_px}px, "
|
f"(gap_px={resolved_gap:.1f}, "
|
||||||
f"bbox_padding={bbox_padding}px)...")
|
f"bbox_padding={bbox_padding}px)...")
|
||||||
|
|
||||||
bubble_dict, bbox_dict, ocr_quads = cluster_into_bubbles(
|
bubble_dict, bbox_dict, ocr_quads = group_quads_by_overlap(
|
||||||
filtered,
|
filtered,
|
||||||
image_shape = full_image.shape,
|
image_shape = full_image.shape,
|
||||||
eps = eps,
|
gap_px = resolved_gap,
|
||||||
proximity_px = proximity_px,
|
|
||||||
bbox_padding = bbox_padding,
|
bbox_padding = bbox_padding,
|
||||||
)
|
)
|
||||||
print(f" ✅ {len(bubble_dict)} bubble(s) after merge.\n")
|
print(f" ✅ {len(bubble_dict)} bubble(s) detected.\n")
|
||||||
|
|
||||||
# ── 8. Debug clusters ─────────────────────────────────────────
|
# ── 8. Debug ──────────────────────────────────────────────────
|
||||||
if debug:
|
if debug:
|
||||||
save_debug_clusters(image_path, filtered,
|
save_debug_clusters(image_path, filtered,
|
||||||
bubble_dict, bbox_dict)
|
bubble_dict, bbox_dict)
|
||||||
@@ -579,7 +544,7 @@ def translate_manga_text(
|
|||||||
if score < quality_threshold:
|
if score < quality_threshold:
|
||||||
print(f" → Re-reading #{i} from crop...")
|
print(f" → Re-reading #{i} from crop...")
|
||||||
reread = reread_cluster_crop(
|
reread = reread_cluster_crop(
|
||||||
full_image, bbox_dict[i], reader, source_lang,
|
full_image, bbox_dict[i], reader,
|
||||||
upscale_factor=upscale_factor,
|
upscale_factor=upscale_factor,
|
||||||
)
|
)
|
||||||
if reread:
|
if reread:
|
||||||
@@ -588,32 +553,37 @@ def translate_manga_text(
|
|||||||
else:
|
else:
|
||||||
print(f" → Nothing found, keeping original.")
|
print(f" → Nothing found, keeping original.")
|
||||||
|
|
||||||
# ── 11. Translate & print ─────────────────────────────────────
|
# ── 11. Translate ─────────────────────────────────────────────
|
||||||
|
# Output format (pipe-delimited, unambiguous):
|
||||||
|
# #ID|ORIGINAL TEXT|TRANSLATED TEXT
|
||||||
print()
|
print()
|
||||||
header = (f"{'BUBBLE':<8} "
|
header = "BUBBLE|ORIGINAL|TRANSLATED"
|
||||||
f"{'ORIGINAL (Italian)':<50} "
|
divider = "─" * 80
|
||||||
f"{'TRANSLATED (Catalan)'}")
|
output_lines = [header, divider]
|
||||||
divider = "─" * 105
|
translations = {}
|
||||||
output_lines = [header, divider]
|
translated_count = 0
|
||||||
print(header)
|
|
||||||
|
print(f"{'BUBBLE':<8} {'ORIGINAL':<45} {'TRANSLATED'}")
|
||||||
print(divider)
|
print(divider)
|
||||||
|
|
||||||
translated_count = 0
|
|
||||||
for i in sorted(clean_bubbles.keys()):
|
for i in sorted(clean_bubbles.keys()):
|
||||||
bubble_text = clean_bubbles[i].strip()
|
bubble_text = clean_bubbles[i].strip()
|
||||||
if not bubble_text:
|
if not bubble_text:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
translated = translator.translate(bubble_text)
|
result = translator.translate(bubble_text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
translated = f"[Translation error: {e}]"
|
result = f"[Translation error: {e}]"
|
||||||
if translated is None:
|
if result is None:
|
||||||
translated = "[No translation returned]"
|
result = "[No translation returned]"
|
||||||
|
|
||||||
|
result = result.upper()
|
||||||
|
translations[i] = result
|
||||||
translated_count += 1
|
translated_count += 1
|
||||||
line = f"#{i:<7} {bubble_text:<50} {translated}"
|
|
||||||
print(line)
|
# Pipe-delimited line — safe regardless of text content
|
||||||
output_lines.append(line)
|
output_lines.append(f"#{i}|{bubble_text}|{result}")
|
||||||
|
print(f"#{i:<7} {bubble_text:<45} {result}")
|
||||||
|
|
||||||
output_lines.append(divider)
|
output_lines.append(divider)
|
||||||
summary = (f"✅ Done! {translated_count} bubble(s) "
|
summary = (f"✅ Done! {translated_count} bubble(s) "
|
||||||
@@ -624,25 +594,17 @@ def translate_manga_text(
|
|||||||
|
|
||||||
# ── 12. Export translations ───────────────────────────────────
|
# ── 12. Export translations ───────────────────────────────────
|
||||||
if export_to_file:
|
if export_to_file:
|
||||||
with open(export_to_file, "w", encoding="utf-8") as f:
|
write_output(output_lines, export_to_file)
|
||||||
f.write("\n".join(output_lines))
|
|
||||||
print(f"📄 Translations saved → {export_to_file}")
|
|
||||||
|
|
||||||
# ── 13. Export bubble boxes ───────────────────────────────────
|
# ── 13. Export bubble boxes ───────────────────────────────────
|
||||||
if export_bubbles_to:
|
if export_bubbles_to:
|
||||||
export_bubble_boxes(bbox_dict, ocr_quads,
|
export_bubble_boxes(
|
||||||
filepath=export_bubbles_to)
|
bbox_dict,
|
||||||
|
ocr_quads,
|
||||||
|
filepath = export_bubbles_to,
|
||||||
# ─────────────────────────────────────────────
|
bbox_expand_ratio = 0.1, # ← tune this
|
||||||
# HELPER
|
image_shape = full_image.shape,
|
||||||
# ─────────────────────────────────────────────
|
)
|
||||||
def list_languages():
|
|
||||||
print(f"\n{'LANGUAGE':<30} {'CODE'}")
|
|
||||||
print("─" * 40)
|
|
||||||
for name, code in SUPPORTED_LANGUAGES.items():
|
|
||||||
print(f"{name:<30} {code}")
|
|
||||||
print("─" * 40)
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -650,18 +612,17 @@ def list_languages():
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
translate_manga_text(
|
translate_manga_text(
|
||||||
image_path = "page.png",
|
image_path = "002-page.jpg",
|
||||||
source_lang = "it",
|
source_lang = "en",
|
||||||
target_lang = "ca",
|
target_lang = "ca",
|
||||||
confidence_threshold = 0.10,
|
confidence_threshold = 0.10,
|
||||||
min_text_length = 2,
|
min_text_length = 2,
|
||||||
export_to_file = "output.txt",
|
export_to_file = "output.txt",
|
||||||
export_bubbles_to = "bubbles.json",
|
export_bubbles_to = "bubbles.json",
|
||||||
cluster_eps = "auto",
|
gap_px = "auto",
|
||||||
proximity_px = 80,
|
|
||||||
filter_sound_effects = True,
|
filter_sound_effects = True,
|
||||||
quality_threshold = 0.5,
|
quality_threshold = 0.5,
|
||||||
upscale_factor = 2.5,
|
upscale_factor = 2.5,
|
||||||
bbox_padding = 5,
|
bbox_padding = 1,
|
||||||
debug = True,
|
debug = True,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user