Spaces:
Sleeping
Sleeping
Deploy Dataset-Maker: torn-page non-overlapping dataset generator
Browse files- .github/workflows/python-app.yml +39 -0
- .gitignore +13 -0
- CLAUDE.md +39 -0
- README.md +114 -6
- app.py +226 -0
- assets/preview_torn.png +0 -0
- pytest.ini +5 -0
- requirements.txt +9 -0
- src/__init__.py +3 -0
- src/config.py +46 -0
- src/noise.py +61 -0
- src/optimizer.py +38 -0
- src/packager.py +93 -0
- src/pdf_loader.py +85 -0
- src/pipeline.py +64 -0
- src/queue_manager.py +84 -0
- src/sampling.py +99 -0
- src/tearing.py +156 -0
- src/workspace.py +72 -0
- tests/test_partition.py +73 -0
- tests/test_queue.py +31 -0
- tests/test_workspace.py +34 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|