diffu_test / diffu /data /ingest.py
Gabriel's picture
Diffu Studio ZeroGPU Space: app.py + vendored packages + aokit AoT + redesigned panel
333ff0e verified
Raw
History Blame Contribute Delete
7.78 kB
"""Ingest ALREADY-cropped (line image, transcription) data into the Diffu manifest + writer-disjoint
split. The pre-cropped counterpart to ``prepare.py`` (which crops from PAGE/ALTO XML); both produce the
same ``manifest.jsonl`` + ``train/val/test.jsonl`` via the shared ``dataset.write_splits``.
Two input shapes, both deriving the writer/source id automatically — no manual labelling:
# A folder tree, one folder per page/volume, with sidecar text files (line_002.png + line_002.txt):
diffu-ingest --dir crops/ --out-dir data_out # writer id = the folder name
# OR a CSV / JSONL you already have (image, text columns); writer from a column or the path:
diffu-ingest --input lines.jsonl --out-dir data_out --writer-col scribe
diffu-ingest --input lines.csv --out-dir data_out --writer-depth 1 # writer = image's parent dir
Pure stdlib + the dataset helpers — no torch.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
from collections.abc import Iterator
from .dataset import char_coverage, normalize_text, write_splits
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp")
def writer_from_path(image_path: str, depth: int = 1) -> str:
"""Writer/source proxy = the ``depth``-th parent directory of the image (1 = immediate folder)."""
p = os.path.dirname(image_path)
for _ in range(depth - 1):
p = os.path.dirname(p)
return os.path.basename(p) or "unknown"
def scan_folder(root: str, *, text_suffix: str = ".txt", writer_depth: int = 1) -> list[dict[str, str]]:
"""Walk ``root`` for line images, pair each with its sidecar text file, derive the writer from the
folder. Images without a sidecar (or with empty text) are skipped.
"""
records: list[dict[str, str]] = []
for dirpath, _dirs, files in os.walk(root):
for name in files:
stem, ext = os.path.splitext(name)
if ext.lower() not in _IMAGE_EXTS:
continue
image = os.path.join(dirpath, name)
sidecar = os.path.join(dirpath, stem + text_suffix)
if not os.path.exists(sidecar):
continue
with open(sidecar, encoding="utf-8") as f:
text = normalize_text(f.read())
if text:
records.append(
{"image": image, "text": text, "writer_id": writer_from_path(image, writer_depth)}
)
return records
def _read_rows(input_path: str) -> Iterator[dict[str, str]]:
"""Yield raw rows from a ``.jsonl`` or ``.csv`` file (auto-detected by extension)."""
if input_path.endswith((".jsonl", ".ndjson")):
with open(input_path, encoding="utf-8") as f:
for line in f:
if line.strip():
yield json.loads(line)
elif input_path.endswith(".csv"):
with open(input_path, encoding="utf-8", newline="") as f:
yield from csv.DictReader(f)
else:
raise ValueError(f"unsupported input {input_path!r}: expected a .jsonl or .csv file")
def read_records(
input_path: str,
*,
image_col: str = "image",
text_col: str = "text",
writer_col: str | None = "writer_id",
writer_depth: int | None = None,
image_root: str | None = None,
) -> list[dict[str, str]]:
"""Read CSV/JSONL records into the manifest schema ``{image, text, writer_id}``.
The writer id comes from ``writer_col`` if given, else (when ``writer_depth`` is set) from the
image's parent directory. Text is NFC-normalized; empty-text rows are dropped.
Raises:
KeyError: if the first row is missing a requested column (image/text/writer).
"""
records: list[dict[str, str]] = []
for i, row in enumerate(_read_rows(input_path)):
if i == 0:
needed = {image_col, text_col} | ({writer_col} if writer_col and writer_depth is None else set())
missing = needed - set(row)
if missing:
raise KeyError(
f"input is missing column(s) {sorted(missing)}; found {sorted(row)}. "
f"Pass --image-col / --text-col / --writer-col, or use --writer-depth."
)
text = normalize_text(str(row[text_col]))
if not text:
continue
image = str(row[image_col])
if image_root:
image = os.path.join(image_root, image)
if writer_depth is not None:
writer = writer_from_path(image, writer_depth)
elif writer_col is not None:
writer = str(row[writer_col])
else: # library callers can pass both None; fail fast (an assert would vanish under python -O)
raise ValueError("read_records needs writer_col or writer_depth set; both are None")
records.append({"image": image, "text": text, "writer_id": writer})
return records
def write_manifest(records: list[dict[str, str]], out_dir: str) -> str:
"""Write records to ``out_dir/manifest.jsonl``; returns the path."""
os.makedirs(out_dir, exist_ok=True)
manifest = os.path.join(out_dir, "manifest.jsonl")
with open(manifest, "w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
return manifest
def main() -> None:
ap = argparse.ArgumentParser(
description="Ingest pre-cropped (image, text) lines -> manifest + writer-disjoint split."
)
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument(
"--dir", help="root folder of line crops + sidecar text files (one folder per page/volume)"
)
src.add_argument("--input", help="a .jsonl or .csv of line records")
ap.add_argument("--out-dir", required=True, help="output dir for manifest.jsonl + train/val/test.jsonl")
ap.add_argument("--text-suffix", default=".txt", help="sidecar transcription extension (folder mode)")
ap.add_argument(
"--writer-depth",
type=int,
default=None,
help="derive writer id from the N-th parent dir of the image (1 = its folder). "
"Folder mode defaults to 1; in --input mode this overrides --writer-col.",
)
ap.add_argument("--image-col", default="image")
ap.add_argument("--text-col", default="text")
ap.add_argument("--writer-col", default="writer_id")
ap.add_argument("--image-root", default=None, help="prefix for relative image paths (--input mode)")
ap.add_argument("--val-frac", type=float, default=0.1)
ap.add_argument("--test-frac", type=float, default=0.1)
ap.add_argument("--seed", type=int, default=42)
args = ap.parse_args()
if args.dir:
records = scan_folder(args.dir, text_suffix=args.text_suffix, writer_depth=args.writer_depth or 1)
else:
records = read_records(
args.input,
image_col=args.image_col,
text_col=args.text_col,
writer_col=args.writer_col,
writer_depth=args.writer_depth,
image_root=args.image_root,
)
if not records:
raise SystemExit("no (image, text) records found — check --dir / --text-suffix or the CSV columns.")
manifest = write_manifest(records, args.out_dir)
n_writers = len({r["writer_id"] for r in records})
print(f"wrote {len(records)} lines from {n_writers} writers/sources -> {manifest}")
cov = char_coverage(manifest)
print("Swedish diacritic coverage:", {ch: cov.get(ch, 0) for ch in "åäöÅÄÖ"})
for name, counts in write_splits(
manifest, args.out_dir, val_frac=args.val_frac, test_frac=args.test_frac, seed=args.seed
).items():
print(f" {name}: {counts['lines']} lines / {counts['groups']} writers")
if __name__ == "__main__":
main()