arittrabag commited on
Commit
a8784d9
·
verified ·
1 Parent(s): f55548c

Deploy Dataset-Maker: torn-page non-overlapping dataset generator

Browse files
.github/workflows/python-app.yml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This workflow will install Python dependencies, run tests and lint with a single version of Python
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3
+
4
+ name: Python application
5
+
6
+ on:
7
+ push:
8
+ branches: [ "main" ]
9
+ pull_request:
10
+ branches: [ "main" ]
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ build:
17
+
18
+ runs-on: ubuntu-latest
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - name: Set up Python 3.10
23
+ uses: actions/setup-python@v3
24
+ with:
25
+ python-version: "3.10"
26
+ - name: Install dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install flake8 pytest
30
+ if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
31
+ - name: Lint with flake8
32
+ run: |
33
+ # stop the build if there are Python syntax errors or undefined names
34
+ flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
35
+ # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
36
+ flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
37
+ - name: Test with pytest
38
+ run: |
39
+ pytest
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ .env
7
+ .DS_Store
8
+ *.zip
9
+ *.pdf
10
+ tmp/
11
+ .gradio/
12
+ flagged/
13
+ .pytest_cache/
CLAUDE.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dataset-Maker - agent guide
2
+
3
+ Gradio web app (HuggingFace Spaces, free tier) that tears PDF pages into
4
+ **non-overlapping** torn fragments for image-stitching datasets.
5
+
6
+ ## Run / test
7
+ ```bash
8
+ pip install -r requirements.txt
9
+ python app.py # serves on :7860
10
+ pytest -q # tests/ : partition invariants + queue ordering
11
+ ```
12
+ `requirements.txt` is pinned for HF Spaces' Python 3.10. Locally on newer
13
+ Pythons you may need unpinned numpy/scipy/pillow/pymupdf.
14
+
15
+ ## Architecture (data flow)
16
+ `app.py` → `src/pipeline.py` → `pdf_loader` → priority `queue_manager` →
17
+ `tearing` → `packager`.
18
+
19
+ - **`src/tearing.py` is the core.** Invariant: output is a strict PARTITION of
20
+ the page (every pixel in exactly one piece → no overlap). Implemented as
21
+ nearest-seed `argmin` (Voronoi) over a value-noise **domain-warped** pixel
22
+ grid. Warp the *query coords*, never the partition rule, or you break the
23
+ no-overlap guarantee. `verify_partition()` gates this at runtime.
24
+ - **`src/queue_manager.py`** - binary min-heap priority queue. Documented Big-O
25
+ in the module docstring; keep `push`/`pop` at `O(log n)`.
26
+ - **`src/noise.py` / `src/sampling.py`** - pure NumPy, no Perlin/extra deps.
27
+ - Per-page seed = `master_seed*1_000_003 + page_index` → randomness changes per
28
+ page yet stays reproducible. Don't make it global.
29
+
30
+ ## Conventions
31
+ - Keep `src/` UI-free (no `gradio` imports) so it stays testable. Gradio lives
32
+ only in `app.py`.
33
+ - Images are `(H, W, 3)` uint8 RGB end-to-end; pieces use black background.
34
+ - `manifest.json` `(x, y)` offsets ARE the stitching labels - don't change the
35
+ schema without updating `packager.py` README.txt + tests.
36
+
37
+ ## Deploy
38
+ HF Spaces reads the YAML header in `README.md` (`sdk: gradio`, `app_file`).
39
+ Push to the Space repo; Spaces installs `requirements.txt` and runs `app.py`.
README.md CHANGED
@@ -1,13 +1,121 @@
1
  ---
2
  title: Dataset Maker
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Dataset Maker
3
+ emoji: 🧩
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 4.44.1
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
  ---
12
 
13
+ # 🧩 Dataset-Maker
14
+
15
+ Turn any PDF into a **non-overlapping torn-fragment dataset** for image
16
+ stitching / fragment-reassembly research. Each page is rendered to A4, torn into
17
+ irregular organic pieces on a black background, and exported as a ZIP with
18
+ stitching ground truth.
19
+
20
+ Built to run on the **HuggingFace Spaces free tier** (Gradio SDK).
21
+
22
+ ## The guarantee: zero overlap
23
+
24
+ The pieces form a strict **partition** of the page - every pixel belongs to
25
+ **exactly one** fragment, so pieces never overlap and together cover the page
26
+ exactly. That makes the `(x, y)` offset of each piece an exact stitching label.
27
+
28
+ How: instead of drawing blobs (which overlap), we assign each pixel to its
29
+ **nearest seed point** (`argmin`). That is a Voronoi tessellation - already a
30
+ perfect partition. To make edges look *torn* instead of straight, we
31
+ **domain-warp** the pixel grid with value noise *before* the nearest-seed test.
32
+ Warping the query points (not the rule) keeps the result a partition while
33
+ boundaries become jagged and organic.
34
+
35
+ Inspired by Voronoi-jigsaw / eroded-boundary fragment literature:
36
+ [Eroded Boundaries](https://arxiv.org/pdf/1912.00755),
37
+ [Pairwise Irregular Fragments](https://arxiv.org/pdf/2507.09767),
38
+ [Deepzzle](https://arxiv.org/abs/2005.12548).
39
+
40
+ ## Pipeline
41
+
42
+ ```
43
+ PDF ──▶ render @DPI ──▶ fit/slice to A4 ──▶ priority queue (cheap pages first)
44
+
45
+ per-page seed ─────────▼
46
+ domain-warped Voronoi partition (no overlap)
47
+
48
+ crop to bbox, black bg │
49
+
50
+ PNG-optimize ──▶ ZIP + manifest.json (x,y labels)
51
+ ```
52
+
53
+ ## Performance notes
54
+
55
+ * **Gradio queue** - `demo.queue(max_size, default_concurrency_limit)` caps load
56
+ for the 2-vCPU free tier; the heavy event has its own `concurrency_limit`.
57
+ * **Priority queue** (`src/queue_manager.py`) - binary min-heap, `push`/`pop`
58
+ `O(log n)`, `peek` `Θ(1)`, space `Θ(n)`. Orders page jobs cheap-first so the
59
+ user sees early progress and tail latency drops. Best case `Ω(1)` per op; no
60
+ comparison heap beats `Ω(log n)` amortized under interleaved ops.
61
+ * **Vectorized partition** - SciPy `cKDTree` nearest-seed query is
62
+ `O(H·W·log S)`; mask extraction is `Θ(H·W)`.
63
+ * **Export** - tight bbox crop + PNG `optimize`/`compress_level=9`; optional
64
+ median-cut palette for smaller archives.
65
+
66
+ ## Output layout
67
+
68
+ ```
69
+ pieces/page_0001/piece_000.png # fragment on black bg
70
+ manifest.json # per-piece {file, x, y, w, h} = stitching GT
71
+ # + per-page adjacency [[i, j], ...] neighbor pairs
72
+ README.txt # reassembly snippet
73
+ ```
74
+
75
+ Each page also lists **`adjacency`** - undirected `[i, j]` piece-index pairs that
76
+ share a torn border (4-connectivity). Use as positive pairs for pairwise /
77
+ graph-based stitching models (Deepzzle / PairingNet style); any unlisted pair is
78
+ a negative. Computed in one vectorized `Θ(H·W)` pass from the partition map - no
79
+ measurable pipeline overhead.
80
+
81
+ ## Run locally
82
+
83
+ Use **Python 3.10–3.12**. Python 3.13/3.14 have no PyMuPDF wheel yet and fall
84
+ back to a source build.
85
+
86
+ ```bash
87
+ python3.11 -m venv .venv && source .venv/bin/activate
88
+ pip install -r requirements.txt
89
+ python app.py # http://127.0.0.1:7860
90
+ pytest -q # invariant + queue tests
91
+ ```
92
+
93
+ ### Pinned web stack (don't loosen)
94
+
95
+ `gradio==4.44.1` needs a matching server stack. Newer auto-resolved versions
96
+ break it, so these are pinned in `requirements.txt`:
97
+
98
+ | pin | why |
99
+ |-----|-----|
100
+ | `fastapi==0.112.4` / `starlette==0.38.6` | starlette ≥0.29 reordered `TemplateResponse` args → gradio passes a dict as template name → `TypeError: unhashable type: 'dict'` on every page load |
101
+ | `huggingface_hub==0.25.2` | hub ≥1.0 removed `HfFolder` that gradio 4.44 imports |
102
+ | `pydantic==2.10.6` | pydantic ≥2.11 emits bool `additionalProperties` → gradio_client 1.3.0 `get_api_info()` crashes |
103
+
104
+ ## Layout
105
+
106
+ ```
107
+ app.py Gradio UI, queue config, theme
108
+ src/pdf_loader.py PDF -> A4 RGB pages (PyMuPDF), tall-page slicing
109
+ src/sampling.py Bridson Poisson-disk seed sampling
110
+ src/noise.py Vectorized value noise (domain warp)
111
+ src/tearing.py Voronoi partition + warp + piece extraction ← core
112
+ src/optimizer.py PNG optimization / quantization
113
+ src/queue_manager.py Priority job queue (min-heap)
114
+ src/packager.py ZIP + manifest builder
115
+ src/pipeline.py PDF -> torn pages orchestration
116
+ tests/ partition no-overlap + queue ordering
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT
app.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset-Maker - Gradio web app (HuggingFace Spaces ready).
2
+
3
+ Upload a PDF -> each page is rendered to A4, torn into NON-OVERLAPPING fragments
4
+ on a black background, and packaged as a ZIP with stitching ground truth.
5
+
6
+ Performance:
7
+ * Gradio `.queue()` caps concurrent requests for the 2-vCPU free tier.
8
+ * A priority queue (src/queue_manager.py) orders page jobs cheap-first.
9
+ * NumPy/SciPy vectorized partition; PNG-optimized export.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import gradio as gr
14
+
15
+ from src import config, workspace
16
+ from src.optimizer import encode_preview
17
+ from src.packager import build_zip
18
+ from src.pipeline import process_pdf, save_temp_pdf
19
+ from src.tearing import verify_partition
20
+
21
+
22
+ def _resolve_theme(name: str):
23
+ """Resolve a registry theme, falling back gracefully across Gradio versions.
24
+
25
+ Some themes (Ocean, Citrus) only exist in Gradio 5+. On older Gradio we fall
26
+ back to Default rather than crashing at startup.
27
+ """
28
+ cls_name, kwargs = config.THEME_REGISTRY.get(
29
+ name, config.THEME_REGISTRY[config.DEFAULT_THEME]
30
+ )
31
+ cls = getattr(gr.themes, cls_name, None) or getattr(gr.themes, "Default")
32
+ return cls(**kwargs)
33
+
34
+
35
+ def available_themes() -> list[str]:
36
+ """Registry themes actually present in the installed Gradio build."""
37
+ return [
38
+ name for name, (cls, _) in config.THEME_REGISTRY.items()
39
+ if getattr(gr.themes, cls, None) is not None
40
+ ]
41
+
42
+
43
+ def _pieces_gallery(pages, max_pieces: int = 60):
44
+ """Flatten a few torn pieces for the preview gallery (downscaled)."""
45
+ out = []
46
+ for pi, page in enumerate(pages):
47
+ for k, piece in enumerate(page.pieces):
48
+ out.append((encode_preview(piece.rgb, 256), f"p{pi+1}·{k}"))
49
+ if len(out) >= max_pieces:
50
+ return out
51
+ return out
52
+
53
+
54
+ def generate(
55
+ pdf_file,
56
+ dpi: int,
57
+ n_pieces: int,
58
+ noise_strength: float,
59
+ noise_scale: float,
60
+ lossy: bool,
61
+ seed: int,
62
+ progress=gr.Progress(),
63
+ ):
64
+ """Main event handler: PDF -> (status, gallery, zip path)."""
65
+ if pdf_file is None:
66
+ raise gr.Error("Upload a PDF first.")
67
+
68
+ # Drop temp files from the previous run so disk stays at steady state
69
+ # (~1 ZIP) instead of growing every generate. HF free-tier disk is small.
70
+ workspace.clear_all()
71
+
72
+ progress(0.02, desc="Reading PDF…")
73
+ with open(pdf_file, "rb") as fh:
74
+ pdf_bytes = fh.read()
75
+ if len(pdf_bytes) > config.MAX_UPLOAD_MB * 1024 * 1024:
76
+ raise gr.Error(f"PDF exceeds {config.MAX_UPLOAD_MB} MB limit.")
77
+
78
+ tmp_pdf = save_temp_pdf(pdf_bytes)
79
+ pages = process_pdf(
80
+ tmp_pdf,
81
+ dpi=int(dpi),
82
+ n_pieces=int(n_pieces),
83
+ noise_strength=float(noise_strength),
84
+ noise_scale=float(noise_scale),
85
+ master_seed=int(seed),
86
+ progress=lambda f, m: progress(0.05 + 0.8 * f, desc=m),
87
+ )
88
+ # Input PDF is fully rendered into `pages` now; free it immediately.
89
+ workspace.discard(tmp_pdf)
90
+
91
+ # Verify the no-overlap invariant on the first page (sanity gate).
92
+ report = verify_partition(pages[0])
93
+ if not report["is_partition"]:
94
+ raise gr.Error(
95
+ f"Partition check failed: overlap={report['max_overlap']}, "
96
+ f"uncovered={report['uncovered_pixels']}"
97
+ )
98
+
99
+ progress(0.9, desc="Packaging ZIP…")
100
+ zip_bytes, manifest = build_zip(
101
+ pages,
102
+ source_name="upload.pdf",
103
+ dpi=int(dpi),
104
+ noise_strength=float(noise_strength),
105
+ noise_scale=float(noise_scale),
106
+ lossy=lossy,
107
+ )
108
+ out_path = workspace.new_temp(suffix="_dataset.zip")
109
+ with open(out_path, "wb") as fh:
110
+ fh.write(zip_bytes)
111
+
112
+ status = (
113
+ f"✅ {len(pages)} pages · {manifest['total_pieces']} pieces · "
114
+ f"no-overlap verified (max_overlap={report['max_overlap']}, "
115
+ f"uncovered={report['uncovered_pixels']})"
116
+ )
117
+ progress(1.0, desc="Done")
118
+ # Order: gallery, zip, status. Status is consumed by a chained .then() with
119
+ # progress hidden, so no progress bar paints over the status text strip.
120
+ return _pieces_gallery(pages), out_path, status
121
+
122
+
123
+ def clear_all():
124
+ """Delete tracked temp files (PDFs + ZIPs) and reset the UI outputs."""
125
+ removed = workspace.clear_all()
126
+ status = f"🧹 Cleared {removed} temp file(s). Upload a PDF and hit **Generate**."
127
+ # outputs order: pdf_in, status, gallery, zip_out
128
+ return None, status, None, None
129
+
130
+
131
+ # Cap the preview gallery and scroll *inside* it. Gradio 4.44's Gallery `height`
132
+ # caps the root but the inner thumbnail grid (.grid-wrap) overflows the page
133
+ # instead of scrolling, so force overflow on the inner container directly.
134
+ _GALLERY_CSS = """
135
+ #piece-gallery { max-height: 70vh; }
136
+ #piece-gallery .grid-wrap,
137
+ #piece-gallery .thumbnails {
138
+ max-height: 70vh;
139
+ overflow-y: auto;
140
+ }
141
+ """
142
+
143
+
144
+ def build_ui(theme_name: str = config.DEFAULT_THEME) -> gr.Blocks:
145
+ with gr.Blocks(
146
+ theme=_resolve_theme(theme_name),
147
+ title="Dataset-Maker · Torn-page stitching dataset",
148
+ css=_GALLERY_CSS,
149
+ ) as demo:
150
+ gr.Markdown(
151
+ "# 🧩 Dataset-Maker\n"
152
+ "Tear PDF pages into **non-overlapping** torn fragments for "
153
+ "image-stitching datasets. Every pixel lands in exactly one piece - "
154
+ "guaranteed by a domain-warped Voronoi partition."
155
+ )
156
+ with gr.Row():
157
+ with gr.Column(scale=1):
158
+ pdf_in = gr.File(label="PDF", file_types=[".pdf"], type="filepath")
159
+ n_pieces = gr.Slider(
160
+ config.MIN_PIECES, config.MAX_PIECES, config.DEFAULT_PIECES,
161
+ step=1, label="Pieces per page",
162
+ )
163
+ with gr.Accordion("Tearing controls", open=False):
164
+ noise_strength = gr.Slider(
165
+ 0, 80, config.DEFAULT_NOISE_STRENGTH, step=1,
166
+ label="Tear jaggedness (px)",
167
+ )
168
+ noise_scale = gr.Slider(
169
+ 8, 200, config.DEFAULT_NOISE_SCALE, step=1,
170
+ label="Tear smoothness (wavelength px)",
171
+ )
172
+ dpi = gr.Slider(
173
+ config.MIN_DPI, config.MAX_DPI, config.DEFAULT_DPI, step=1,
174
+ label="Render DPI",
175
+ )
176
+ seed = gr.Number(value=0, precision=0, label="Master seed")
177
+ lossy = gr.Checkbox(
178
+ value=False, label="Lossy palette PNG (smaller ZIP)"
179
+ )
180
+ with gr.Row():
181
+ run = gr.Button("Generate dataset", variant="primary")
182
+ clear = gr.Button("Clear all", variant="secondary")
183
+ with gr.Column(scale=2):
184
+ status = gr.Markdown("Upload a PDF and hit **Generate**.")
185
+ gallery = gr.Gallery(
186
+ label="Torn pieces (preview)", columns=6, height=420,
187
+ object_fit="contain", elem_id="piece-gallery",
188
+ )
189
+ zip_out = gr.File(label="Download dataset (.zip)")
190
+
191
+ # Status flows through a State, then into the Markdown via a hidden-
192
+ # progress .then() — keeps the progress bars on gallery + zip only,
193
+ # not over the thin status text (4.44 has no per-output show_progress).
194
+ status_state = gr.State("")
195
+
196
+ run.click(
197
+ generate,
198
+ inputs=[pdf_in, dpi, n_pieces, noise_strength, noise_scale, lossy, seed],
199
+ outputs=[gallery, zip_out, status_state],
200
+ concurrency_limit=config.WORKER_CONCURRENCY, # heavy job throttle
201
+ ).then(
202
+ lambda s: s,
203
+ inputs=status_state,
204
+ outputs=status,
205
+ show_progress="hidden",
206
+ )
207
+ clear.click(
208
+ clear_all,
209
+ inputs=None,
210
+ outputs=[pdf_in, status, gallery, zip_out],
211
+ )
212
+ gr.Markdown(
213
+ "Pieces sit on black backgrounds; `manifest.json` carries each "
214
+ "piece's `(x, y)` offset = the stitching label."
215
+ )
216
+ return demo
217
+
218
+
219
+ demo = build_ui()
220
+ demo.queue(
221
+ max_size=config.QUEUE_MAX_SIZE,
222
+ default_concurrency_limit=config.WORKER_CONCURRENCY,
223
+ )
224
+
225
+ if __name__ == "__main__":
226
+ demo.launch(share=True)
assets/preview_torn.png ADDED
pytest.ini ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [pytest]
2
+ # Put the repo root on sys.path so `import src...` works under bare `pytest`
3
+ # (CI), not only `python -m pytest` (which adds CWD implicitly).
4
+ pythonpath = .
5
+ testpaths = tests
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio==4.44.1
2
+ huggingface_hub==0.25.2
3
+ pydantic==2.10.6
4
+ fastapi==0.112.4
5
+ starlette==0.38.6
6
+ PyMuPDF==1.24.10
7
+ numpy==1.26.4
8
+ scipy==1.13.1
9
+ Pillow==10.4.0
src/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Dataset-Maker: tear PDF pages into non-overlapping fragments for stitching."""
2
+
3
+ __version__ = "1.0.0"
src/config.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central configuration: A4 geometry, defaults, theme registry.
2
+
3
+ All tunables live here so the UI, workers and tests share one source of truth.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ # --- A4 geometry -----------------------------------------------------------
8
+ # A4 = 210 x 297 mm. Pixel size scales with DPI: px = mm / 25.4 * dpi.
9
+ A4_MM = (210.0, 297.0)
10
+ A4_ASPECT = A4_MM[1] / A4_MM[0] # height / width ~= 1.4142
11
+
12
+ DEFAULT_DPI = 150 # 150 DPI A4 -> 1240 x 1754 px. Good detail/size trade-off.
13
+ MIN_DPI, MAX_DPI = 72, 300
14
+
15
+ # --- Tearing defaults ------------------------------------------------------
16
+ DEFAULT_PIECES = 16 # pieces per page (n^2 grid feel -> here a flat count)
17
+ MIN_PIECES, MAX_PIECES = 2, 256
18
+
19
+ DEFAULT_NOISE_STRENGTH = 28.0 # px of boundary displacement (the "tear" jaggedness)
20
+ DEFAULT_NOISE_SCALE = 96.0 # px wavelength of the noise (bigger = smoother tears)
21
+
22
+ # --- Performance / limits --------------------------------------------------
23
+ MAX_PAGES_PER_PDF = 60 # guardrail for the HF free tier (CPU/RAM bound)
24
+ MAX_UPLOAD_MB = 50
25
+ QUEUE_MAX_SIZE = 32 # Gradio request queue cap
26
+ WORKER_CONCURRENCY = 1 # HF free tier = 2 vCPU; keep 1 heavy job at a time
27
+
28
+ # --- Theme registry (Gradio built-ins, see theming guide) ------------------
29
+ # name -> (gr.themes class name, kwargs). Resolved lazily in app.py to avoid
30
+ # importing gradio inside worker/test code paths.
31
+ THEME_REGISTRY = {
32
+ "Ocean": ("Ocean", {}),
33
+ "Soft": ("Soft", {}),
34
+ "Glass": ("Glass", {}),
35
+ "Monochrome": ("Monochrome", {}),
36
+ "Citrus": ("Citrus", {}),
37
+ "Default": ("Default", {}),
38
+ }
39
+ DEFAULT_THEME = "Soft" # present in Gradio 4 and 5; Ocean/Citrus are 5-only
40
+
41
+
42
+ def a4_pixels(dpi: int) -> tuple[int, int]:
43
+ """Return (width, height) in px for an A4 page at the given DPI."""
44
+ w = round(A4_MM[0] / 25.4 * dpi)
45
+ h = round(A4_MM[1] / 25.4 * dpi)
46
+ return w, h
src/noise.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vectorized value-noise field for domain warping.
2
+
3
+ We do NOT pull in a Perlin dependency: a tileable value-noise (random lattice +
4
+ bilinear upsample, summed across octaves) is enough to warp Voronoi boundaries
5
+ into organic "torn" edges, and it is pure NumPy.
6
+
7
+ Complexity: building an (H, W) field over `octaves` is
8
+ Theta(H * W * octaves) time,
9
+ Theta(H * W) space.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+
16
+ def _bilinear_upsample(grid: np.ndarray, out_h: int, out_w: int) -> np.ndarray:
17
+ """Upsample a small lattice to (out_h, out_w) with bilinear interpolation."""
18
+ gh, gw = grid.shape
19
+ # Sample positions in lattice space.
20
+ ys = np.linspace(0, gh - 1, out_h)
21
+ xs = np.linspace(0, gw - 1, out_w)
22
+ y0 = np.floor(ys).astype(np.int64)
23
+ x0 = np.floor(xs).astype(np.int64)
24
+ y1 = np.minimum(y0 + 1, gh - 1)
25
+ x1 = np.minimum(x0 + 1, gw - 1)
26
+ wy = (ys - y0)[:, None]
27
+ wx = (xs - x0)[None, :]
28
+
29
+ top = grid[y0][:, x0] * (1 - wx) + grid[y0][:, x1] * wx
30
+ bot = grid[y1][:, x0] * (1 - wx) + grid[y1][:, x1] * wx
31
+ return top * (1 - wy) + bot * wy
32
+
33
+
34
+ def value_noise(
35
+ h: int,
36
+ w: int,
37
+ scale: float,
38
+ rng: np.random.Generator,
39
+ octaves: int = 3,
40
+ persistence: float = 0.5,
41
+ ) -> np.ndarray:
42
+ """Return an (h, w) float field in roughly [-1, 1].
43
+
44
+ `scale` is the wavelength in pixels of the base octave: larger -> smoother.
45
+ Octaves add finer detail (fractal/fBm), persistence weights their amplitude.
46
+ """
47
+ field = np.zeros((h, w), dtype=np.float32)
48
+ amplitude = 1.0
49
+ total_amp = 0.0
50
+ freq_scale = max(scale, 2.0)
51
+
52
+ for _ in range(max(1, octaves)):
53
+ gh = max(2, int(np.ceil(h / freq_scale)) + 1)
54
+ gw = max(2, int(np.ceil(w / freq_scale)) + 1)
55
+ lattice = rng.uniform(-1.0, 1.0, size=(gh, gw)).astype(np.float32)
56
+ field += amplitude * _bilinear_upsample(lattice, h, w)
57
+ total_amp += amplitude
58
+ amplitude *= persistence
59
+ freq_scale = max(freq_scale * 0.5, 2.0)
60
+
61
+ return field / total_amp
src/optimizer.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image optimization for the exported pieces.
2
+
3
+ Torn pieces are mostly black background -> they compress extremely well as PNG.
4
+ We:
5
+ * tight-crop to the fragment bbox (already done in tearing),
6
+ * optionally quantize the foreground to <=256 colors (palette PNG) to shrink
7
+ size further when `lossy` is on,
8
+ * always write with PNG optimize + max zlib compression.
9
+
10
+ Palette quantization is Theta(P) over piece pixels (Pillow median-cut).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import io
15
+
16
+ import numpy as np
17
+ from PIL import Image
18
+
19
+
20
+ def encode_piece(rgb: np.ndarray, lossy: bool = False, colors: int = 64) -> bytes:
21
+ """Encode an (h, w, 3) uint8 piece to optimized PNG bytes."""
22
+ img = Image.fromarray(rgb, mode="RGB")
23
+ if lossy:
24
+ # Median-cut palette; black background collapses to one palette entry.
25
+ img = img.quantize(colors=max(2, min(256, colors)), method=Image.MEDIANCUT)
26
+ buf = io.BytesIO()
27
+ img.save(buf, format="PNG", optimize=True, compress_level=9)
28
+ return buf.getvalue()
29
+
30
+
31
+ def encode_preview(rgb: np.ndarray, max_side: int = 1024) -> np.ndarray:
32
+ """Downscale a page/preview for fast UI display (keeps aspect)."""
33
+ H, W = rgb.shape[:2]
34
+ scale = min(1.0, max_side / max(H, W))
35
+ if scale >= 1.0:
36
+ return rgb
37
+ nw, nh = int(W * scale), int(H * scale)
38
+ return np.asarray(Image.fromarray(rgb).resize((nw, nh), Image.BILINEAR))
src/packager.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Package torn pieces + stitching ground-truth into a downloadable ZIP.
2
+
3
+ Layout inside the archive:
4
+ pieces/page_0001/piece_000.png ...
5
+ manifest.json # global summary + per-piece placement (x, y, w, h)
6
+ README.txt # how to reassemble
7
+
8
+ The manifest IS the dataset label: each piece's (x, y) offset on its page is the
9
+ exact stitching target. Reassembling = paste every piece at its offset.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import json
15
+ import zipfile
16
+ from datetime import datetime, timezone
17
+
18
+ from .optimizer import encode_piece
19
+ from .tearing import TornPage
20
+
21
+
22
+ def build_zip(
23
+ pages: list[TornPage],
24
+ *,
25
+ source_name: str,
26
+ dpi: int,
27
+ noise_strength: float,
28
+ noise_scale: float,
29
+ lossy: bool,
30
+ ) -> tuple[bytes, dict]:
31
+ """Return (zip_bytes, manifest_dict) for a list of torn pages."""
32
+ manifest = {
33
+ "generator": "Dataset-Maker",
34
+ "created_utc": datetime.now(timezone.utc).isoformat(),
35
+ "source": source_name,
36
+ "dpi": dpi,
37
+ "noise_strength": noise_strength,
38
+ "noise_scale": noise_scale,
39
+ "lossy": lossy,
40
+ "pages": [],
41
+ "total_pieces": 0,
42
+ }
43
+
44
+ buf = io.BytesIO()
45
+ with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
46
+ for pi, page in enumerate(pages):
47
+ pdir = f"pieces/page_{pi + 1:04d}"
48
+ page_entry = {
49
+ "index": pi,
50
+ "width": page.width,
51
+ "height": page.height,
52
+ # Undirected neighbor pairs (piece-index i, j) = which fragments
53
+ # share a torn border. Positive pairs for pairwise/graph stitching
54
+ # models; non-listed pairs are negatives.
55
+ "adjacency": [[int(i), int(j)] for i, j in page.adjacency],
56
+ "pieces": [],
57
+ }
58
+ for k, piece in enumerate(page.pieces):
59
+ fname = f"{pdir}/piece_{k:03d}.png"
60
+ zf.writestr(fname, encode_piece(piece.rgb, lossy=lossy))
61
+ h, w = piece.mask.shape
62
+ page_entry["pieces"].append(
63
+ {"file": fname, "x": piece.x, "y": piece.y, "w": w, "h": h}
64
+ )
65
+ manifest["total_pieces"] += len(page.pieces)
66
+ manifest["pages"].append(page_entry)
67
+
68
+ zf.writestr("manifest.json", json.dumps(manifest, indent=2))
69
+ zf.writestr("README.txt", _README)
70
+
71
+ return buf.getvalue(), manifest
72
+
73
+
74
+ _README = """Dataset-Maker export
75
+ =====================
76
+ Each page was torn into NON-OVERLAPPING fragments (a strict partition: every
77
+ pixel belongs to exactly one piece). Fragments sit on a black background.
78
+
79
+ Each page also carries `adjacency`: a list of [i, j] piece-index pairs that
80
+ share a torn border (4-connectivity, undirected, i < j). Use as positive pairs
81
+ for pairwise/graph-based stitching models; any pair not listed is a negative.
82
+
83
+ To reassemble a page (stitching ground truth):
84
+ import json
85
+ from PIL import Image
86
+ m = json.load(open("manifest.json"))
87
+ for page in m["pages"]:
88
+ canvas = Image.new("RGB", (page["width"], page["height"]))
89
+ for p in page["pieces"]:
90
+ piece = Image.open(p["file"])
91
+ canvas.paste(piece, (p["x"], p["y"]), mask=...) # non-black pixels
92
+ canvas.save(f"reassembled_{page['index']}.png")
93
+ """
src/pdf_loader.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PDF -> list of A4-sized RGB page images using PyMuPDF (fitz).
2
+
3
+ "Native split" handling: if a page is already ~A4 portrait we render it as-is.
4
+ If a page is much larger / a different ratio (e.g. an A3 spread, a long scan),
5
+ we slice it into A4-height bands so downstream tearing always works on A4 tiles.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ from .config import A4_ASPECT, MAX_PAGES_PER_PDF, a4_pixels
12
+
13
+
14
+ def _page_pixmap(page, dpi: int) -> np.ndarray:
15
+ import fitz # PyMuPDF
16
+
17
+ zoom = dpi / 72.0
18
+ pm = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
19
+ arr = np.frombuffer(pm.samples, dtype=np.uint8).reshape(pm.h, pm.w, pm.n)
20
+ if pm.n == 4: # RGBA -> RGB
21
+ arr = arr[:, :, :3]
22
+ elif pm.n == 1: # gray -> RGB
23
+ arr = np.repeat(arr, 3, axis=2)
24
+ return np.ascontiguousarray(arr)
25
+
26
+
27
+ def _fit_to_a4(img: np.ndarray, dpi: int) -> list[np.ndarray]:
28
+ """Return one or more A4-portrait tiles covering `img`.
29
+
30
+ Tall pages are sliced into A4-height bands (each band padded to A4 width).
31
+ """
32
+ target_w, target_h = a4_pixels(dpi)
33
+ H, W = img.shape[:2]
34
+ aspect = H / W
35
+
36
+ # Close enough to A4 portrait: letterbox-resize the whole page onto A4.
37
+ if abs(aspect - A4_ASPECT) < 0.12:
38
+ return [_letterbox(img, target_w, target_h)]
39
+
40
+ # Otherwise scale to A4 width, then slice the (now-tall) image into bands.
41
+ scale = target_w / W
42
+ new_h = max(1, int(round(H * scale)))
43
+ resized = _resize(img, target_w, new_h)
44
+ tiles = []
45
+ for top in range(0, new_h, target_h):
46
+ band = resized[top:top + target_h]
47
+ if band.shape[0] < target_h:
48
+ band = _letterbox(band, target_w, target_h)
49
+ tiles.append(band)
50
+ return tiles
51
+
52
+
53
+ def _resize(img: np.ndarray, w: int, h: int) -> np.ndarray:
54
+ from PIL import Image
55
+
56
+ return np.asarray(Image.fromarray(img).resize((w, h), Image.LANCZOS))
57
+
58
+
59
+ def _letterbox(img: np.ndarray, w: int, h: int) -> np.ndarray:
60
+ """Resize preserving aspect, pad with white onto a w*h canvas."""
61
+ H, W = img.shape[:2]
62
+ scale = min(w / W, h / H)
63
+ nw, nh = max(1, int(W * scale)), max(1, int(H * scale))
64
+ resized = _resize(img, nw, nh)
65
+ canvas = np.full((h, w, 3), 255, dtype=np.uint8)
66
+ oy, ox = (h - nh) // 2, (w - nw) // 2
67
+ canvas[oy:oy + nh, ox:ox + nw] = resized
68
+ return canvas
69
+
70
+
71
+ def load_pdf_pages(path: str, dpi: int) -> list[np.ndarray]:
72
+ """Render `path` into a list of A4 RGB uint8 page images."""
73
+ import fitz
74
+
75
+ doc = fitz.open(path)
76
+ try:
77
+ out: list[np.ndarray] = []
78
+ for page in doc:
79
+ raw = _page_pixmap(page, dpi)
80
+ out.extend(_fit_to_a4(raw, dpi))
81
+ if len(out) >= MAX_PAGES_PER_PDF:
82
+ return out[:MAX_PAGES_PER_PDF]
83
+ return out
84
+ finally:
85
+ doc.close()
src/pipeline.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orchestration: PDF bytes -> torn pages, scheduled through the priority queue.
2
+
3
+ Kept UI-free so it is unit-testable and reusable from a CLI or batch worker.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Callable
8
+
9
+ from . import workspace
10
+ from .pdf_loader import load_pdf_pages
11
+ from .queue_manager import PriorityJobQueue, page_priority
12
+ from .tearing import TornPage, tear_page
13
+
14
+
15
+ def process_pdf(
16
+ pdf_path: str,
17
+ *,
18
+ dpi: int,
19
+ n_pieces: int,
20
+ noise_strength: float,
21
+ noise_scale: float,
22
+ master_seed: int = 0,
23
+ progress: Callable[[float, str], None] | None = None,
24
+ ) -> list[TornPage]:
25
+ """Render + tear every page, ordered by the priority queue (cheap first)."""
26
+ pages = load_pdf_pages(pdf_path, dpi)
27
+ if not pages:
28
+ raise ValueError("No renderable pages found in PDF.")
29
+
30
+ queue = PriorityJobQueue()
31
+ for idx, page_img in enumerate(pages):
32
+ queue.push(page_priority(n_pieces, idx), payload=(idx, page_img))
33
+
34
+ total = len(pages)
35
+ results: dict[int, TornPage] = {}
36
+ done = 0
37
+ while True:
38
+ job = queue.pop()
39
+ if job is None:
40
+ break
41
+ idx, page_img = job.payload
42
+ # Per-page seed -> randomness changes page by page, yet reproducible.
43
+ seed = (master_seed * 1_000_003 + idx) & 0x7FFFFFFF
44
+ results[idx] = tear_page(
45
+ page_img,
46
+ n_pieces=n_pieces,
47
+ seed=seed,
48
+ noise_strength=noise_strength,
49
+ noise_scale=noise_scale,
50
+ )
51
+ done += 1
52
+ if progress:
53
+ progress(done / total, f"Torn page {done}/{total}")
54
+
55
+ # Return in document order for a coherent manifest.
56
+ return [results[i] for i in sorted(results)]
57
+
58
+
59
+ def save_temp_pdf(file_bytes: bytes) -> str:
60
+ """Persist uploaded bytes to a tracked temp file PyMuPDF can open."""
61
+ path = workspace.new_temp(suffix=".pdf")
62
+ with open(path, "wb") as fh:
63
+ fh.write(file_bytes)
64
+ return path
src/queue_manager.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Priority job queue for batch dataset generation.
2
+
3
+ Why a custom queue when Gradio already queues HTTP requests? Gradio's queue is
4
+ FIFO over *requests*. Here a single request can enqueue many *page jobs*, and we
5
+ want cheap jobs (few pieces / small pages) to clear first so the user sees early
6
+ progress and tail latency drops. That ordering is a priority queue's job.
7
+
8
+ Data structure: binary min-heap (heapq) keyed by a (priority, seq) tuple.
9
+ `seq` is a monotonic counter that breaks ties FIFO and keeps ordering stable
10
+ without comparing the payloads.
11
+
12
+ Complexity:
13
+ push : O(log n) worst & average
14
+ pop : O(log n) worst & average
15
+ peek : Theta(1)
16
+ space : Theta(n)
17
+ Big-Omega: push/pop are Omega(1) in the best case (no sift needed); the heap can
18
+ never beat Omega(log n) amortized when both ops interleave under arbitrary keys.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import heapq
23
+ import itertools
24
+ import threading
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Callable
27
+
28
+
29
+ @dataclass(order=False)
30
+ class Job:
31
+ priority: float
32
+ payload: Any
33
+ seq: int = field(default=0)
34
+
35
+
36
+ class PriorityJobQueue:
37
+ """Thread-safe min-heap priority queue. Lower priority value = runs first."""
38
+
39
+ def __init__(self) -> None:
40
+ self._heap: list[tuple[float, int, Job]] = []
41
+ self._counter = itertools.count()
42
+ self._lock = threading.Lock()
43
+
44
+ def __len__(self) -> int:
45
+ with self._lock:
46
+ return len(self._heap)
47
+
48
+ def push(self, priority: float, payload: Any) -> Job:
49
+ """O(log n). Smaller `priority` is dequeued earlier; ties are FIFO."""
50
+ with self._lock:
51
+ seq = next(self._counter)
52
+ job = Job(priority=priority, payload=payload, seq=seq)
53
+ heapq.heappush(self._heap, (priority, seq, job))
54
+ return job
55
+
56
+ def pop(self) -> Job | None:
57
+ """O(log n). Returns the highest-priority job, or None if empty."""
58
+ with self._lock:
59
+ if not self._heap:
60
+ return None
61
+ return heapq.heappop(self._heap)[2]
62
+
63
+ def peek(self) -> Job | None:
64
+ """Theta(1). Look at the next job without removing it."""
65
+ with self._lock:
66
+ return self._heap[0][2] if self._heap else None
67
+
68
+ def drain(self, handler: Callable[[Job], Any]) -> list[Any]:
69
+ """Pop every job in priority order, applying `handler`. Returns results."""
70
+ results = []
71
+ while True:
72
+ job = self.pop()
73
+ if job is None:
74
+ break
75
+ results.append(handler(job))
76
+ return results
77
+
78
+
79
+ def page_priority(piece_count: int, page_index: int) -> float:
80
+ """Cheaper pages first; page_index breaks ties to keep document order-ish.
81
+
82
+ Cost grows with piece_count (more masks to extract), so use it as the key.
83
+ """
84
+ return float(piece_count) * 1000.0 + float(page_index)
src/sampling.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed-point sampling for the Voronoi partition.
2
+
3
+ Bridson Poisson-disk sampling gives blue-noise seeds: random but evenly spread,
4
+ so piece areas stay within sane bounds (no slivers, no giant blobs). We target
5
+ a piece count, derive the min-distance r from area, then top up / trim to hit
6
+ the exact count.
7
+
8
+ Bridson is Theta(k) in the number of accepted samples (grid-accelerated
9
+ neighbour test), Theta(grid cells) = Theta(W*H / r^2) space.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+
15
+ import numpy as np
16
+
17
+
18
+ def poisson_disk(
19
+ width: int,
20
+ height: int,
21
+ radius: float,
22
+ rng: np.random.Generator,
23
+ k: int = 30,
24
+ ) -> np.ndarray:
25
+ """Bridson's algorithm. Returns (n, 2) float array of (x, y) samples."""
26
+ cell = radius / math.sqrt(2)
27
+ gw = int(math.ceil(width / cell))
28
+ gh = int(math.ceil(height / cell))
29
+ grid = -np.ones((gh, gw), dtype=np.int64)
30
+ samples: list[tuple[float, float]] = []
31
+ active: list[int] = []
32
+
33
+ def grid_xy(p):
34
+ return int(p[0] / cell), int(p[1] / cell)
35
+
36
+ first = (rng.uniform(0, width), rng.uniform(0, height))
37
+ samples.append(first)
38
+ gx, gy = grid_xy(first)
39
+ grid[gy, gx] = 0
40
+ active.append(0)
41
+
42
+ while active:
43
+ idx = active[rng.integers(len(active))]
44
+ base = samples[idx]
45
+ placed = False
46
+ for _ in range(k):
47
+ ang = rng.uniform(0, 2 * math.pi)
48
+ rad = rng.uniform(radius, 2 * radius)
49
+ cand = (base[0] + rad * math.cos(ang), base[1] + rad * math.sin(ang))
50
+ if not (0 <= cand[0] < width and 0 <= cand[1] < height):
51
+ continue
52
+ cgx, cgy = grid_xy(cand)
53
+ ok = True
54
+ for ny in range(max(0, cgy - 2), min(gh, cgy + 3)):
55
+ for nx in range(max(0, cgx - 2), min(gw, cgx + 3)):
56
+ j = grid[ny, nx]
57
+ if j >= 0:
58
+ d = math.dist(cand, samples[j])
59
+ if d < radius:
60
+ ok = False
61
+ break
62
+ if not ok:
63
+ break
64
+ if ok:
65
+ samples.append(cand)
66
+ grid[cgy, cgx] = len(samples) - 1
67
+ active.append(len(samples) - 1)
68
+ placed = True
69
+ break
70
+ if not placed:
71
+ active.remove(idx)
72
+
73
+ return np.asarray(samples, dtype=np.float32)
74
+
75
+
76
+ def sample_seeds(
77
+ width: int,
78
+ height: int,
79
+ n_pieces: int,
80
+ rng: np.random.Generator,
81
+ ) -> np.ndarray:
82
+ """Return exactly `n_pieces` blue-noise seeds (x, y) inside the page."""
83
+ n_pieces = max(2, int(n_pieces))
84
+ # r from target area per piece: area ~ W*H/n, packing factor ~0.7.
85
+ area_per = (width * height) / n_pieces
86
+ radius = math.sqrt(area_per) * 0.66
87
+
88
+ pts = poisson_disk(width, height, radius, rng)
89
+
90
+ # Top up with uniform random points if Poisson under-shot.
91
+ if len(pts) < n_pieces:
92
+ extra = rng.uniform([0, 0], [width, height], size=(n_pieces - len(pts), 2))
93
+ pts = np.vstack([pts, extra.astype(np.float32)])
94
+ # Trim deterministically if it over-shot.
95
+ if len(pts) > n_pieces:
96
+ keep = rng.choice(len(pts), size=n_pieces, replace=False)
97
+ pts = pts[keep]
98
+
99
+ return pts.astype(np.float32)
src/tearing.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core: tear a page image into non-overlapping torn pieces.
2
+
3
+ Guarantee (the whole point of the dataset): the output is a PARTITION of the
4
+ page. Every pixel is assigned to exactly one piece via nearest-seed argmin, so
5
+ pieces never overlap and together cover the page exactly -> perfect ground
6
+ truth for image stitching.
7
+
8
+ The "torn" look comes from DOMAIN WARPING: before computing the nearest seed we
9
+ displace each pixel's coordinate by a value-noise vector. Warping the query
10
+ points (not the partition rule) keeps the result a strict partition while making
11
+ boundaries jagged/organic instead of straight Voronoi edges.
12
+
13
+ Complexity (H*W pixels, S seeds, kd-tree):
14
+ nearest-seed query : O(H*W * log S) time
15
+ label / mask pass : Theta(H*W) time, Theta(H*W) space
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ import numpy as np
22
+ from scipy.spatial import cKDTree
23
+
24
+ from .noise import value_noise
25
+ from .sampling import sample_seeds
26
+
27
+
28
+ @dataclass
29
+ class Piece:
30
+ """One torn fragment + its placement for reassembly ground truth."""
31
+ label: int
32
+ x: int # left offset on the original page canvas
33
+ y: int # top offset
34
+ rgb: np.ndarray # (h, w, 3) uint8, black where outside the fragment
35
+ mask: np.ndarray # (h, w) bool, True inside the fragment
36
+
37
+
38
+ @dataclass
39
+ class TornPage:
40
+ width: int
41
+ height: int
42
+ pieces: list[Piece]
43
+ labels: np.ndarray # (H, W) int32 partition map (for verification / GT)
44
+ adjacency: list[tuple[int, int]] # undirected (i, j) piece-index neighbor pairs
45
+
46
+
47
+ def _adjacency_pairs(labels: np.ndarray) -> np.ndarray:
48
+ """Return unique unordered raw-label neighbor pairs from a partition map.
49
+
50
+ 4-connectivity: two pieces are neighbors iff they touch horizontally or
51
+ vertically. Vectorized: compare each pixel to its right/down neighbor, keep
52
+ label pairs that differ. Cost Theta(H*W) - a few ms even at 150 DPI, dwarfed
53
+ by the kd-tree query, so no measurable pipeline slowdown.
54
+ """
55
+ h_a, h_b = labels[:, :-1], labels[:, 1:]
56
+ v_a, v_b = labels[:-1, :], labels[1:, :]
57
+ hd, vd = h_a != h_b, v_a != v_b
58
+ pairs = np.concatenate(
59
+ [
60
+ np.stack([h_a[hd], h_b[hd]], axis=1),
61
+ np.stack([v_a[vd], v_b[vd]], axis=1),
62
+ ],
63
+ axis=0,
64
+ )
65
+ if pairs.size == 0: # single-piece page
66
+ return pairs.reshape(0, 2)
67
+ pairs.sort(axis=1) # (min, max) -> undirected
68
+ return np.unique(pairs, axis=0)
69
+
70
+
71
+ def compute_adjacency(
72
+ labels: np.ndarray, label_to_idx: dict[int, int]
73
+ ) -> list[tuple[int, int]]:
74
+ """Map raw-label neighbor pairs to manifest piece indices, sorted."""
75
+ out = []
76
+ for a, b in _adjacency_pairs(labels):
77
+ ia, ib = label_to_idx.get(int(a)), label_to_idx.get(int(b))
78
+ if ia is not None and ib is not None and ia != ib:
79
+ out.append((ia, ib) if ia < ib else (ib, ia))
80
+ return sorted(set(out))
81
+
82
+
83
+ def tear_page(
84
+ page_rgb: np.ndarray,
85
+ n_pieces: int,
86
+ seed: int,
87
+ noise_strength: float,
88
+ noise_scale: float,
89
+ ) -> TornPage:
90
+ """Partition `page_rgb` (H, W, 3 uint8) into `n_pieces` torn fragments.
91
+
92
+ `seed` makes a page reproducible; pass a different seed per page so the
93
+ randomness changes page by page.
94
+ """
95
+ if page_rgb.ndim != 3 or page_rgb.shape[2] != 3:
96
+ raise ValueError("page_rgb must be (H, W, 3) uint8")
97
+ H, W = page_rgb.shape[:2]
98
+ rng = np.random.default_rng(seed)
99
+
100
+ seeds = sample_seeds(W, H, n_pieces, rng) # (S, 2) -> (x, y)
101
+
102
+ # Domain-warp the pixel grid with two independent noise fields.
103
+ ys, xs = np.mgrid[0:H, 0:W]
104
+ wx = value_noise(H, W, noise_scale, rng) * noise_strength
105
+ wy = value_noise(H, W, noise_scale, rng) * noise_strength
106
+ qx = (xs + wx).ravel()
107
+ qy = (ys + wy).ravel()
108
+ query = np.stack([qx, qy], axis=1).astype(np.float32)
109
+
110
+ tree = cKDTree(seeds)
111
+ _, flat_labels = tree.query(query, k=1, workers=-1)
112
+ labels = flat_labels.reshape(H, W).astype(np.int32)
113
+
114
+ pieces: list[Piece] = []
115
+ for lbl in np.unique(labels):
116
+ mask = labels == lbl
117
+ ys_idx, xs_idx = np.nonzero(mask)
118
+ if ys_idx.size == 0:
119
+ continue
120
+ y0, y1 = int(ys_idx.min()), int(ys_idx.max()) + 1
121
+ x0, x1 = int(xs_idx.min()), int(xs_idx.max()) + 1
122
+ sub_mask = mask[y0:y1, x0:x1]
123
+ rgb = np.zeros((y1 - y0, x1 - x0, 3), dtype=np.uint8) # black background
124
+ rgb[sub_mask] = page_rgb[y0:y1, x0:x1][sub_mask]
125
+ pieces.append(
126
+ Piece(label=int(lbl), x=x0, y=y0, rgb=rgb, mask=sub_mask)
127
+ )
128
+
129
+ # Piece-index <-> raw-label map from the pieces we actually emitted, so
130
+ # adjacency indices line up exactly with the manifest's piece ordering.
131
+ label_to_idx = {p.label: i for i, p in enumerate(pieces)}
132
+ adjacency = compute_adjacency(labels, label_to_idx)
133
+
134
+ return TornPage(
135
+ width=W, height=H, pieces=pieces, labels=labels, adjacency=adjacency
136
+ )
137
+
138
+
139
+ def verify_partition(torn: TornPage) -> dict:
140
+ """Assert the no-overlap / full-coverage invariants. Returns a report.
141
+
142
+ Reassembles a coverage counter from piece masks at their offsets and checks
143
+ every pixel is covered exactly once.
144
+ """
145
+ cover = np.zeros((torn.height, torn.width), dtype=np.int32)
146
+ for p in torn.pieces:
147
+ h, w = p.mask.shape
148
+ cover[p.y:p.y + h, p.x:p.x + w] += p.mask
149
+ max_cover = int(cover.max())
150
+ min_cover = int(cover.min())
151
+ return {
152
+ "pieces": len(torn.pieces),
153
+ "max_overlap": max_cover, # must be 1 -> no overlap
154
+ "uncovered_pixels": int((cover == 0).sum()), # must be 0 -> full cover
155
+ "is_partition": max_cover == 1 and min_cover == 1,
156
+ }
src/workspace.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Temp-file registry so 'Clear all' genuinely frees disk, not just the UI.
2
+
3
+ Every PDF/ZIP scratch file goes through `new_temp`, which records the path. A
4
+ later `clear_all` unlinks every recorded file. Thread-safe (a min worker pool
5
+ on HF still shares this process). UI-free so `src/` stays testable.
6
+
7
+ Note: this clears the *file cache* we create. Gradio's own request queue is
8
+ per-request and transient (a handler can't flush other users' pending events),
9
+ and the priority queue in `queue_manager` is built and drained within a single
10
+ `process_pdf` call - neither leaves persistent state to clear.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import tempfile
16
+ import threading
17
+
18
+ _lock = threading.Lock()
19
+ _tracked: set[str] = set()
20
+
21
+
22
+ def new_temp(suffix: str = "") -> str:
23
+ """Create a tracked temp file and return its path (handle closed)."""
24
+ fd, path = tempfile.mkstemp(suffix=suffix)
25
+ os.close(fd)
26
+ with _lock:
27
+ _tracked.add(path)
28
+ return path
29
+
30
+
31
+ def register(path: str) -> None:
32
+ """Track an externally created path so clear_all() will remove it."""
33
+ with _lock:
34
+ _tracked.add(path)
35
+
36
+
37
+ def discard(path: str) -> bool:
38
+ """Unlink one tracked file early (e.g. an input PDF after it's loaded).
39
+
40
+ Returns True if the file was removed. Untracks regardless so a vanished
41
+ file doesn't linger in the registry.
42
+ """
43
+ with _lock:
44
+ _tracked.discard(path)
45
+ try:
46
+ os.remove(path)
47
+ return True
48
+ except OSError:
49
+ return False
50
+
51
+
52
+ def clear_all() -> int:
53
+ """Unlink every tracked temp file. Returns count actually removed."""
54
+ removed = 0
55
+ with _lock:
56
+ # list() snapshot is required: we mutate _tracked (discard) in-loop.
57
+ # Iterating the set directly -> "Set changed size during iteration".
58
+ for path in list(_tracked): # NOSONAR python:S7504 false positive
59
+ try:
60
+ os.remove(path)
61
+ removed += 1
62
+ except FileNotFoundError:
63
+ pass
64
+ except OSError:
65
+ continue # leave it tracked; retry on next clear
66
+ _tracked.discard(path)
67
+ return removed
68
+
69
+
70
+ def tracked_count() -> int:
71
+ with _lock:
72
+ return len(_tracked)
tests/test_partition.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """No-overlap / full-coverage invariant tests - the dataset's core guarantee."""
2
+ import numpy as np
3
+
4
+ from src.tearing import compute_adjacency, tear_page, verify_partition
5
+
6
+
7
+ def _page(h=400, w=300):
8
+ rng = np.random.default_rng(0)
9
+ return rng.integers(0, 255, size=(h, w, 3), dtype=np.uint8)
10
+
11
+
12
+ def test_partition_no_overlap_full_cover():
13
+ torn = tear_page(_page(), n_pieces=12, seed=1,
14
+ noise_strength=20, noise_scale=60)
15
+ rep = verify_partition(torn)
16
+ assert rep["is_partition"], rep
17
+ assert rep["max_overlap"] == 1
18
+ assert rep["uncovered_pixels"] == 0
19
+
20
+
21
+ def test_piece_count_close_to_request():
22
+ torn = tear_page(_page(), n_pieces=16, seed=2,
23
+ noise_strength=25, noise_scale=80)
24
+ # Some seeds can lose their cell after warping; allow a small shortfall.
25
+ assert 10 <= len(torn.pieces) <= 16
26
+
27
+
28
+ def test_per_page_randomness_changes():
29
+ p = _page()
30
+ a = tear_page(p, 12, seed=1, noise_strength=20, noise_scale=60).labels
31
+ b = tear_page(p, 12, seed=2, noise_strength=20, noise_scale=60).labels
32
+ assert not np.array_equal(a, b)
33
+
34
+
35
+ def test_reproducible_same_seed():
36
+ p = _page()
37
+ a = tear_page(p, 12, seed=5, noise_strength=20, noise_scale=60).labels
38
+ b = tear_page(p, 12, seed=5, noise_strength=20, noise_scale=60).labels
39
+ assert np.array_equal(a, b)
40
+
41
+
42
+ def test_adjacency_known_grid():
43
+ # 3x3 label map: pieces 0,1,2 all mutually touch.
44
+ labels = np.array([[0, 0, 1],
45
+ [0, 2, 1],
46
+ [2, 2, 1]], dtype=np.int32)
47
+ adj = compute_adjacency(labels, {0: 0, 1: 1, 2: 2})
48
+ assert adj == [(0, 1), (0, 2), (1, 2)]
49
+
50
+
51
+ def test_adjacency_invariants_on_real_page():
52
+ torn = tear_page(_page(), n_pieces=16, seed=3,
53
+ noise_strength=25, noise_scale=80)
54
+ n = len(torn.pieces)
55
+ adj = torn.adjacency
56
+ # undirected, deduped, sorted, in-range, no self-loops
57
+ assert adj == sorted(set(adj))
58
+ for i, j in adj:
59
+ assert 0 <= i < j < n
60
+ # partition over a connected page -> adjacency graph is connected
61
+ seen = set()
62
+ stack = [0]
63
+ nbrs = {k: set() for k in range(n)}
64
+ for i, j in adj:
65
+ nbrs[i].add(j)
66
+ nbrs[j].add(i)
67
+ while stack:
68
+ u = stack.pop()
69
+ if u in seen:
70
+ continue
71
+ seen.add(u)
72
+ stack.extend(nbrs[u] - seen)
73
+ assert len(seen) == n # every piece reachable
tests/test_queue.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Priority queue ordering + complexity-contract tests."""
2
+ from src.queue_manager import PriorityJobQueue, page_priority
3
+
4
+
5
+ def test_priority_order_cheap_first():
6
+ q = PriorityJobQueue()
7
+ q.push(page_priority(64, 0), "expensive")
8
+ q.push(page_priority(4, 0), "cheap")
9
+ q.push(page_priority(16, 0), "mid")
10
+ order = [q.pop().payload for _ in range(3)]
11
+ assert order == ["cheap", "mid", "expensive"]
12
+
13
+
14
+ def test_fifo_tie_break():
15
+ q = PriorityJobQueue()
16
+ for i in range(5):
17
+ q.push(1.0, i) # identical priority
18
+ assert [q.pop().payload for _ in range(5)] == [0, 1, 2, 3, 4]
19
+
20
+
21
+ def test_pop_empty_returns_none():
22
+ assert PriorityJobQueue().pop() is None
23
+
24
+
25
+ def test_len_and_peek():
26
+ q = PriorityJobQueue()
27
+ q.push(2.0, "b")
28
+ q.push(1.0, "a")
29
+ assert len(q) == 2
30
+ assert q.peek().payload == "a"
31
+ assert len(q) == 2 # peek does not consume
tests/test_workspace.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Temp-file registry: 'Clear all' must genuinely unlink tracked files."""
2
+ import os
3
+
4
+ from src import workspace
5
+
6
+
7
+ def test_new_temp_tracked_then_cleared():
8
+ before = workspace.tracked_count()
9
+ p1 = workspace.new_temp(suffix=".pdf")
10
+ p2 = workspace.new_temp(suffix=".zip")
11
+ assert os.path.exists(p1) and os.path.exists(p2)
12
+ assert workspace.tracked_count() == before + 2
13
+
14
+ removed = workspace.clear_all()
15
+ assert removed >= 2
16
+ assert not os.path.exists(p1) and not os.path.exists(p2)
17
+ assert workspace.tracked_count() == 0
18
+
19
+
20
+ def test_discard_unlinks_and_untracks():
21
+ p = workspace.new_temp(suffix=".pdf")
22
+ before = workspace.tracked_count()
23
+ assert workspace.discard(p) is True
24
+ assert not os.path.exists(p)
25
+ assert workspace.tracked_count() == before - 1
26
+ # idempotent: discarding again is harmless
27
+ assert workspace.discard(p) is False
28
+
29
+
30
+ def test_clear_all_tolerates_missing_file():
31
+ p = workspace.new_temp(suffix=".tmp")
32
+ os.remove(p) # vanish underneath the registry
33
+ workspace.clear_all() # must not raise
34
+ assert workspace.tracked_count() == 0