shufflesnap 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shufflesnap-0.3.0/.github/workflows/ci.yml +64 -0
- shufflesnap-0.3.0/.github/workflows/release.yml +84 -0
- shufflesnap-0.3.0/.gitignore +12 -0
- shufflesnap-0.3.0/CMakeLists.txt +10 -0
- shufflesnap-0.3.0/LICENSE +21 -0
- shufflesnap-0.3.0/PKG-INFO +111 -0
- shufflesnap-0.3.0/PUBLISHING.md +47 -0
- shufflesnap-0.3.0/README.md +130 -0
- shufflesnap-0.3.0/README_PYPI.md +76 -0
- shufflesnap-0.3.0/assets/showcase_triptych_512.png +0 -0
- shufflesnap-0.3.0/examples/basic_usage.py +90 -0
- shufflesnap-0.3.0/examples/benchmark_threads.py +61 -0
- shufflesnap-0.3.0/examples/render_showcase.py +177 -0
- shufflesnap-0.3.0/pyproject.toml +75 -0
- shufflesnap-0.3.0/python/shufflesnap/__init__.py +330 -0
- shufflesnap-0.3.0/src/core.cpp +765 -0
- shufflesnap-0.3.0/tests/test_smoke.py +395 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["main"]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
tests:
|
|
14
|
+
name: Tests (${{ matrix.os }}, py${{ matrix.python-version }})
|
|
15
|
+
runs-on: ${{ matrix.os }}
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
20
|
+
python-version: ["3.10", "3.12", "3.14"]
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v6
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-python@v6
|
|
26
|
+
with:
|
|
27
|
+
python-version: ${{ matrix.python-version }}
|
|
28
|
+
cache: pip
|
|
29
|
+
|
|
30
|
+
- name: Install package and test dependencies
|
|
31
|
+
run: python -m pip install -U pip && python -m pip install -e '.[test]'
|
|
32
|
+
|
|
33
|
+
- name: Run tests
|
|
34
|
+
run: python -m pytest -q
|
|
35
|
+
|
|
36
|
+
dist:
|
|
37
|
+
name: Build distributions
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/checkout@v6
|
|
42
|
+
|
|
43
|
+
- uses: actions/setup-python@v6
|
|
44
|
+
with:
|
|
45
|
+
python-version: "3.14"
|
|
46
|
+
cache: pip
|
|
47
|
+
|
|
48
|
+
- name: Install release tooling
|
|
49
|
+
run: python -m pip install -U pip build twine pytest scipy
|
|
50
|
+
|
|
51
|
+
- name: Build sdist and wheel
|
|
52
|
+
run: python -m build
|
|
53
|
+
|
|
54
|
+
- name: Check distribution metadata
|
|
55
|
+
run: python -m twine check dist/*
|
|
56
|
+
|
|
57
|
+
- name: Smoke test sdist install
|
|
58
|
+
run: python -m pip install --force-reinstall dist/*.tar.gz && python -m pytest -q tests/test_smoke.py
|
|
59
|
+
|
|
60
|
+
- uses: actions/upload-artifact@v6
|
|
61
|
+
with:
|
|
62
|
+
name: ci-dist
|
|
63
|
+
path: dist/*
|
|
64
|
+
if-no-files-found: error
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build-sdist:
|
|
13
|
+
name: Build sdist
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v6
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-python@v6
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.14"
|
|
22
|
+
cache: pip
|
|
23
|
+
|
|
24
|
+
- name: Install build tooling
|
|
25
|
+
run: python -m pip install -U pip build twine pytest scipy
|
|
26
|
+
|
|
27
|
+
- name: Build sdist
|
|
28
|
+
run: python -m build --sdist
|
|
29
|
+
|
|
30
|
+
- name: Check sdist metadata
|
|
31
|
+
run: python -m twine check dist/*
|
|
32
|
+
|
|
33
|
+
- name: Smoke test sdist install
|
|
34
|
+
run: python -m pip install --force-reinstall dist/*.tar.gz && python -m pytest -q tests/test_smoke.py
|
|
35
|
+
|
|
36
|
+
- uses: actions/upload-artifact@v6
|
|
37
|
+
with:
|
|
38
|
+
name: dist-sdist
|
|
39
|
+
path: dist/*
|
|
40
|
+
if-no-files-found: error
|
|
41
|
+
|
|
42
|
+
build-wheels:
|
|
43
|
+
name: Build wheels (${{ matrix.os }})
|
|
44
|
+
runs-on: ${{ matrix.os }}
|
|
45
|
+
env:
|
|
46
|
+
CIBW_ENVIRONMENT_MACOS: MACOSX_DEPLOYMENT_TARGET=11.0
|
|
47
|
+
strategy:
|
|
48
|
+
fail-fast: false
|
|
49
|
+
matrix:
|
|
50
|
+
os: [ubuntu-latest, windows-latest, macos-15-intel, macos-14]
|
|
51
|
+
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/checkout@v6
|
|
54
|
+
|
|
55
|
+
- uses: pypa/cibuildwheel@v3.3.0
|
|
56
|
+
with:
|
|
57
|
+
output-dir: wheelhouse
|
|
58
|
+
|
|
59
|
+
- uses: actions/upload-artifact@v6
|
|
60
|
+
with:
|
|
61
|
+
name: dist-${{ matrix.os }}
|
|
62
|
+
path: wheelhouse/*.whl
|
|
63
|
+
if-no-files-found: error
|
|
64
|
+
|
|
65
|
+
publish:
|
|
66
|
+
name: Publish to PyPI
|
|
67
|
+
runs-on: ubuntu-latest
|
|
68
|
+
needs: [build-sdist, build-wheels]
|
|
69
|
+
environment:
|
|
70
|
+
name: pypi
|
|
71
|
+
url: https://pypi.org/p/shufflesnap
|
|
72
|
+
permissions:
|
|
73
|
+
contents: read
|
|
74
|
+
id-token: write
|
|
75
|
+
|
|
76
|
+
steps:
|
|
77
|
+
- uses: actions/download-artifact@v5
|
|
78
|
+
with:
|
|
79
|
+
pattern: dist-*
|
|
80
|
+
path: dist
|
|
81
|
+
merge-multiple: true
|
|
82
|
+
|
|
83
|
+
- name: Publish package distributions to PyPI
|
|
84
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.18)
|
|
2
|
+
project(shufflesnap LANGUAGES CXX)
|
|
3
|
+
|
|
4
|
+
find_package(Python REQUIRED COMPONENTS Interpreter Development.Module)
|
|
5
|
+
find_package(nanobind CONFIG REQUIRED)
|
|
6
|
+
|
|
7
|
+
nanobind_add_module(_core src/core.cpp)
|
|
8
|
+
target_compile_features(_core PRIVATE cxx_std_17)
|
|
9
|
+
|
|
10
|
+
install(TARGETS _core LIBRARY DESTINATION shufflesnap)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kyle McDonald
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shufflesnap
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Multiscale window solver for large point-to-grid assignment.
|
|
5
|
+
Keywords: assignment,lap,point-cloud,grid,auction,jv
|
|
6
|
+
Author: Kyle McDonald
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
17
|
+
Classifier: Programming Language :: C++
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Project-URL: Homepage, https://github.com/kylemcdonald/shufflesnap
|
|
21
|
+
Project-URL: Repository, https://github.com/kylemcdonald/shufflesnap
|
|
22
|
+
Project-URL: Issues, https://github.com/kylemcdonald/shufflesnap/issues
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: numpy>=1.26
|
|
25
|
+
Provides-Extra: examples
|
|
26
|
+
Requires-Dist: matplotlib>=3.8; extra == "examples"
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=8.3; extra == "test"
|
|
29
|
+
Requires-Dist: scipy>=1.10; extra == "test"
|
|
30
|
+
Provides-Extra: release
|
|
31
|
+
Requires-Dist: build>=1.2; extra == "release"
|
|
32
|
+
Requires-Dist: cibuildwheel>=3.0; extra == "release"
|
|
33
|
+
Requires-Dist: twine>=5.1; extra == "release"
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# shufflesnap
|
|
37
|
+
|
|
38
|
+
`shufflesnap` assigns large 2D point clouds to regular grids: every point gets its own grid cell, and the solver seeks a small total squared movement using multiscale local descent. It is a Python package with a native C++ core and `nanobind` bindings. The main use case is turning embeddings (UMAP, t-SNE, Isomap) into image atlases.
|
|
39
|
+
|
|
40
|
+
Instead of building an `n x n` cost matrix, `shufflesnap` refines a trivial legal assignment by solving exact linear assignment problems inside small grid windows at multiple scales — coarse-to-fine strided windows repair global structure in a handful of rounds, then stride-1 windows polish. The assignment is legal after every round, the cost never increases, memory is linear in the number of points plus grid cells, and random initialization works well on the tested distributions. Final accuracy and the number of polishing rounds have no worst-case guarantee.
|
|
41
|
+
|
|
42
|
+
The public API has three functions:
|
|
43
|
+
|
|
44
|
+
1. `snap_to_grid(points, width=None, height=None, cleanup_seconds=None, ...)`
|
|
45
|
+
2. `window_cleanup(points, initial_assignment, rows, cols, budget_seconds=None, ...)`
|
|
46
|
+
3. `linear_sum_assignment(cost_matrix)`
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python -m pip install shufflesnap
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
To run the matplotlib example from the source tree:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m pip install -e '.[examples]'
|
|
58
|
+
python examples/basic_usage.py
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
### `snap_to_grid(points, width=None, height=None, cleanup_seconds=None, ...)`
|
|
64
|
+
|
|
65
|
+
High-level wrapper for snapping a 2D point cloud onto a destination grid. Inputs must use the same coordinate system as the target grid (default range `[0.03, 0.97]` per axis); the API does not normalize inputs.
|
|
66
|
+
|
|
67
|
+
Behavior:
|
|
68
|
+
|
|
69
|
+
- chooses a destination grid automatically when `width` and `height` are omitted: an exact factorization of `n` with aspect ratio in `[1:1, 2:1]` when one exists, otherwise a slightly larger near-square grid
|
|
70
|
+
- supports any `n <= width * height` directly: the occupied cell subset is selected before cleanup and remains fixed
|
|
71
|
+
- by default cleanup runs until none of the four scheduled half-offset tilings can improve the assignment; `cleanup_seconds` caps the time instead, and `0.0` returns the raw seed (a deterministic random permutation, with a fixed random cell subset when the grid has more cells than points)
|
|
72
|
+
- `polish_all_offsets=True` adds one stride-1 sweep over every complete window placement after normal cleanup, reducing the remaining local error at additional cost
|
|
73
|
+
- pass `mask` (a `(height, width)` bool array) to restrict which cells may be used — shaped atlases (circles, cut corners, a half-empty last row) work out of the box
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
|
|
77
|
+
- `grid_points`: `(n, 2)` float64 NumPy array of assigned destination points in original point order
|
|
78
|
+
- `assignment`: `(n,)` int64 NumPy array of destination-grid cell ids in original point order
|
|
79
|
+
- `(width, height)`: destination-grid size tuple
|
|
80
|
+
|
|
81
|
+
### `window_cleanup(points, initial_assignment, rows, cols, budget_seconds=None, ...)`
|
|
82
|
+
|
|
83
|
+
Improve any legal assignment with the native multiscale window cleanup kernel.
|
|
84
|
+
|
|
85
|
+
Key options:
|
|
86
|
+
|
|
87
|
+
- `budget_seconds=None` runs until converged (a full stride-1 round changes nothing)
|
|
88
|
+
- `strides=None` uses the automatic coarse-to-fine schedule; pass a list to override
|
|
89
|
+
- `window_size=6`
|
|
90
|
+
- `all_offsets=True` uses all `window_size ** 2` tiling offsets instead of the default four and is substantially slower
|
|
91
|
+
- `num_threads=None` to use `std::thread::hardware_concurrency()`
|
|
92
|
+
- `fixed_suffix_count` to keep a suffix of target cells fixed; `cell_mask` to mark which cells may be used at all
|
|
93
|
+
- `trace_rounds=True` to record per-round cost, elapsed time, and stride
|
|
94
|
+
|
|
95
|
+
Returns a dict with `assignment`, `rounds_completed`, `elapsed_s`, `final_cost`, and `converged`.
|
|
96
|
+
|
|
97
|
+
### `linear_sum_assignment(cost_matrix)`
|
|
98
|
+
|
|
99
|
+
Solve a dense square linear assignment problem exactly with the native C++ Jonker-Volgenant implementation.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
|
|
103
|
+
- `row_ind`: `int64` NumPy array of shape `(n,)`
|
|
104
|
+
- `col_ind`: `int64` NumPy array of shape `(n,)`
|
|
105
|
+
- `total_cost`: Python `float`
|
|
106
|
+
|
|
107
|
+
## More
|
|
108
|
+
|
|
109
|
+
- Source repository: https://github.com/kylemcdonald/shufflesnap
|
|
110
|
+
- Issue tracker: https://github.com/kylemcdonald/shufflesnap/issues
|
|
111
|
+
- Example scripts: https://github.com/kylemcdonald/shufflesnap/tree/main/examples
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Publishing `shufflesnap`
|
|
2
|
+
|
|
3
|
+
The distribution name, import package, native extension destination, repository URLs, and GitHub Actions workflow all use `shufflesnap`. The prepared version is 0.3.0.
|
|
4
|
+
|
|
5
|
+
## Current release status
|
|
6
|
+
|
|
7
|
+
The new PyPI project is not published yet. As of 2026-09-15, its public project endpoint returned 404. Publishing a renamed distribution creates a new PyPI project; it does not migrate existing installations or transfer the old project's releases. Keep the previous distribution available for existing users.
|
|
8
|
+
|
|
9
|
+
This workspace has GitHub authentication but no configured PyPI upload credentials. The repository uses GitHub OIDC trusted publishing, which must be configured separately for the new project name.
|
|
10
|
+
|
|
11
|
+
## One-time account setup
|
|
12
|
+
|
|
13
|
+
In [PyPI publishing settings](https://pypi.org/manage/account/publishing/), add a **pending publisher** with:
|
|
14
|
+
|
|
15
|
+
| Field | Value |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| PyPI project name | `shufflesnap` |
|
|
18
|
+
| GitHub owner | `kylemcdonald` |
|
|
19
|
+
| Repository | `shufflesnap` |
|
|
20
|
+
| Workflow filename | `release.yml` |
|
|
21
|
+
| Environment | `pypi` |
|
|
22
|
+
|
|
23
|
+
The workflow filename field takes `release.yml`, not the full path. The repository already has the `pypi` environment. See [PyPI's new-project instructions](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/).
|
|
24
|
+
|
|
25
|
+
## Validate locally
|
|
26
|
+
|
|
27
|
+
From the library repository:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m pip install -U build twine pytest scipy
|
|
31
|
+
python -m build
|
|
32
|
+
python -m twine check dist/*
|
|
33
|
+
python -m pip install --force-reinstall dist/*.whl
|
|
34
|
+
python -m pytest -q
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Test an sdist installation in a separate environment as well. CI checks editable installations on Linux, macOS, and Windows and builds distributions. The release workflow builds wheels for Linux, Windows, and both macOS architectures.
|
|
38
|
+
|
|
39
|
+
## Publish after account setup and review
|
|
40
|
+
|
|
41
|
+
1. Push the reviewed source and confirm CI passes.
|
|
42
|
+
2. Create and push the release tag `v0.3.0`, or dispatch `release.yml` on the reviewed commit.
|
|
43
|
+
3. Confirm all wheel jobs and the PyPI publish job succeed.
|
|
44
|
+
4. In a clean environment, run `pip install shufflesnap` and verify `import shufflesnap`.
|
|
45
|
+
5. Update any release-status prose and confirm the package link before submitting the paper.
|
|
46
|
+
|
|
47
|
+
Do not tag solely to test PyPI configuration: the release workflow publishes publicly. `README_PYPI.md` is the package long description; `README.md` also includes the visual example.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# shufflesnap
|
|
2
|
+
|
|
3
|
+
`shufflesnap` assigns large 2D point clouds to regular grids: every point gets its own grid cell, and the solver seeks a small total squared movement using multiscale local descent. It is a Python package with a native C++ core and `nanobind` bindings.
|
|
4
|
+
|
|
5
|
+
The main use case is turning embeddings (UMAP, t-SNE, Isomap) into image atlases: millions of points, one thumbnail per cell, no overlap.
|
|
6
|
+
|
|
7
|
+
The public API is three functions:
|
|
8
|
+
|
|
9
|
+
1. `snap_to_grid(points, ...)` — the high-level entry point
|
|
10
|
+
2. `window_cleanup(points, initial_assignment, rows, cols, ...)` — the multiscale solver behind it
|
|
11
|
+
3. `linear_sum_assignment(cost_matrix)` — a native dense square Jonker-Volgenant solver
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
`shufflesnap` never builds the `n x n` cost matrix that makes exact dense assignment infeasible at scale (a million points would need 7.3 TiB). Instead it starts from a trivial legal assignment and repeatedly solves exact linear assignment problems inside small windows of the grid — at multiple scales.
|
|
16
|
+
|
|
17
|
+
A window is at most `6x6` cells. At stride 1, windows cover contiguous cells and fix local defects. At stride `s`, the same windows cover every `s`-th cell, so one exact `36`-cell solve can move points all the way across the grid. One coarse-to-fine sweep (stride halving from grid-spanning down to 1, like the gap sequence in shell sort) repairs global structure in a handful of rounds; stride-1 rounds then polish until the budget expires or nothing changes.
|
|
18
|
+
|
|
19
|
+
Each default round uses four tilings offset by half a window on either axis. Shifted tilings are clipped into smaller disjoint windows at the far grid edges; the omitted leading half-window remains covered by the unshifted phases.
|
|
20
|
+
|
|
21
|
+
Properties:
|
|
22
|
+
|
|
23
|
+
- **Anytime and monotone.** The assignment is legal after every round and the cost never increases.
|
|
24
|
+
- **Self-seeding.** The default seed is a deterministic random permutation: on the tested nonuniform benchmarks it usually reaches lower final costs than a raster-sorted seed. There is no worst-case approximation guarantee.
|
|
25
|
+
- **Linear memory.** No global cost matrix, ever.
|
|
26
|
+
- **Parallel.** All windows in a phase are disjoint and solved on native C++ threads.
|
|
27
|
+
- **Predetermined occupancy.** If `n < width * height`, the occupied cell subset is selected before cleanup and remains fixed.
|
|
28
|
+
|
|
29
|
+
## Showcase
|
|
30
|
+
|
|
31
|
+
`512x512` meandering point cloud, rendered as a three-panel hero image on a black background:
|
|
32
|
+
|
|
33
|
+
1. the initial point cloud
|
|
34
|
+
2. a `50%` interpolated view
|
|
35
|
+
3. the final grid
|
|
36
|
+
|
|
37
|
+
All three panels use the same Lab-derived coloring with source `x/y` mapped into `a/b`.
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
From PyPI:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
python -m pip install shufflesnap
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
From a local checkout:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m pip install -e .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## API
|
|
56
|
+
|
|
57
|
+
### `snap_to_grid(points, width=None, height=None, cleanup_seconds=None, window_size=6, margin=0.03, num_threads=None, mask=None, polish_all_offsets=False)`
|
|
58
|
+
|
|
59
|
+
Assign a 2D point cloud to distinct cells of a regular grid.
|
|
60
|
+
|
|
61
|
+
- `points`: `(n, 2)` float64 array-like, in the same coordinates as the target grid (default range `[0.03, 0.97]` on each axis); inputs are not automatically normalized
|
|
62
|
+
- `width`, `height`: destination grid; omitted, a near-square grid with aspect ratio in `[1:1, 2:1]` is chosen (an exact factorization of `n` when one exists, otherwise a slightly larger grid with occupancy fixed before cleanup)
|
|
63
|
+
- `cleanup_seconds`: optional wall-clock cap; by default cleanup runs until none of the four scheduled half-offset tilings can improve the assignment; `0.0` returns the raw seed (a deterministic random permutation, with a fixed random cell subset when the grid has more cells than points)
|
|
64
|
+
- `polish_all_offsets`: after normal cleanup, run one stride-1 sweep over all `window_size ** 2` tiling offsets; this tests every complete window placement and can reduce the remaining local error at additional cost
|
|
65
|
+
- `mask`: optional `(height, width)` bool array restricting which cells may be used, e.g. to shape the atlas or choose its occupied region
|
|
66
|
+
- `num_threads`: `None` uses all hardware threads
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
|
|
70
|
+
- `grid_points`: `(n, 2)` float64 array of assigned grid positions, in input order
|
|
71
|
+
- `assignment`: `(n,)` int64 array of destination cell ids (`row * width + col`)
|
|
72
|
+
- `(width, height)`: the destination grid size
|
|
73
|
+
|
|
74
|
+
### `window_cleanup(points, initial_assignment, rows, cols, budget_seconds=None, window_size=6, margin=0.03, num_threads=None, fixed_suffix_count=0, strides=None, trace_rounds=False, cell_mask=None, all_offsets=False)`
|
|
75
|
+
|
|
76
|
+
Improve any legal assignment with multiscale window cleanup.
|
|
77
|
+
|
|
78
|
+
- `points` may number fewer than `rows * cols`; cells unoccupied in `initial_assignment` remain unavailable throughout cleanup
|
|
79
|
+
- `budget_seconds=None` runs until converged (a full stride-1 round changes nothing)
|
|
80
|
+
- `strides=None` uses `default_stride_schedule(rows, cols, window_size)`: one round per stride, coarse to fine, then stride 1 repeats. Pass a custom list of strides (ints or `(row, col)` pairs) to override; the last entry repeats.
|
|
81
|
+
- `all_offsets=False` uses the four scheduled tilings at offsets `0` and `window_size // 2` on each axis; `True` uses all `window_size ** 2` tiling offsets and is substantially slower
|
|
82
|
+
- `fixed_suffix_count` keeps a suffix of target cells locked; `cell_mask` marks which cells may be used at all
|
|
83
|
+
- `trace_rounds=True` adds per-round `round_elapsed_s`, `round_costs`, `round_strides` arrays to the result
|
|
84
|
+
|
|
85
|
+
Returns a dict with `assignment`, `rounds_completed`, `elapsed_s`, `final_cost`, and `converged`.
|
|
86
|
+
|
|
87
|
+
### `linear_sum_assignment(cost_matrix)`
|
|
88
|
+
|
|
89
|
+
Solve a dense square LAP exactly with the native C++ Jonker-Volgenant implementation. Useful for small problems and for auditing `window_cleanup` results.
|
|
90
|
+
|
|
91
|
+
Returns `(row_ind, col_ind, total_cost)`.
|
|
92
|
+
|
|
93
|
+
## Example
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import numpy as np
|
|
97
|
+
import shufflesnap
|
|
98
|
+
|
|
99
|
+
points = np.random.default_rng(0).random((100_000, 2))
|
|
100
|
+
grid_points, assignment, (width, height) = shufflesnap.snap_to_grid(points)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
See [examples/basic_usage.py](examples/basic_usage.py) for a rendered example:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python -m pip install -e '.[examples]'
|
|
107
|
+
python examples/basic_usage.py
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The showcase image above was generated with:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python examples/render_showcase.py \
|
|
114
|
+
--grid-width 512 \
|
|
115
|
+
--grid-height 512 \
|
|
116
|
+
--image-width 512 \
|
|
117
|
+
--image-height 512 \
|
|
118
|
+
--cleanup-seconds 30 \
|
|
119
|
+
--output assets/showcase_triptych_512.png
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
For release instructions, see [PUBLISHING.md](PUBLISHING.md).
|
|
123
|
+
|
|
124
|
+
## Notes
|
|
125
|
+
|
|
126
|
+
- `linear_sum_assignment()` expects a square cost matrix.
|
|
127
|
+
- The cleanup kernel uses windows of at most `6x6`, so the native small-LAP kernel is specialized for up to `36` occupied cells per window.
|
|
128
|
+
- The native cleanup kernel uses standard C++ threads and does not depend on OpenMP.
|
|
129
|
+
- With `budget_seconds=None` (run to convergence), assignments are deterministic for fixed inputs and parameters, independent of thread count. With a finite budget, the number of completed rounds can vary with machine load.
|
|
130
|
+
- GitHub Actions builds release artifacts for Linux, macOS, and Windows wheels, plus an sdist.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# shufflesnap
|
|
2
|
+
|
|
3
|
+
`shufflesnap` assigns large 2D point clouds to regular grids: every point gets its own grid cell, and the solver seeks a small total squared movement using multiscale local descent. It is a Python package with a native C++ core and `nanobind` bindings. The main use case is turning embeddings (UMAP, t-SNE, Isomap) into image atlases.
|
|
4
|
+
|
|
5
|
+
Instead of building an `n x n` cost matrix, `shufflesnap` refines a trivial legal assignment by solving exact linear assignment problems inside small grid windows at multiple scales — coarse-to-fine strided windows repair global structure in a handful of rounds, then stride-1 windows polish. The assignment is legal after every round, the cost never increases, memory is linear in the number of points plus grid cells, and random initialization works well on the tested distributions. Final accuracy and the number of polishing rounds have no worst-case guarantee.
|
|
6
|
+
|
|
7
|
+
The public API has three functions:
|
|
8
|
+
|
|
9
|
+
1. `snap_to_grid(points, width=None, height=None, cleanup_seconds=None, ...)`
|
|
10
|
+
2. `window_cleanup(points, initial_assignment, rows, cols, budget_seconds=None, ...)`
|
|
11
|
+
3. `linear_sum_assignment(cost_matrix)`
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python -m pip install shufflesnap
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
To run the matplotlib example from the source tree:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python -m pip install -e '.[examples]'
|
|
23
|
+
python examples/basic_usage.py
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## API
|
|
27
|
+
|
|
28
|
+
### `snap_to_grid(points, width=None, height=None, cleanup_seconds=None, ...)`
|
|
29
|
+
|
|
30
|
+
High-level wrapper for snapping a 2D point cloud onto a destination grid. Inputs must use the same coordinate system as the target grid (default range `[0.03, 0.97]` per axis); the API does not normalize inputs.
|
|
31
|
+
|
|
32
|
+
Behavior:
|
|
33
|
+
|
|
34
|
+
- chooses a destination grid automatically when `width` and `height` are omitted: an exact factorization of `n` with aspect ratio in `[1:1, 2:1]` when one exists, otherwise a slightly larger near-square grid
|
|
35
|
+
- supports any `n <= width * height` directly: the occupied cell subset is selected before cleanup and remains fixed
|
|
36
|
+
- by default cleanup runs until none of the four scheduled half-offset tilings can improve the assignment; `cleanup_seconds` caps the time instead, and `0.0` returns the raw seed (a deterministic random permutation, with a fixed random cell subset when the grid has more cells than points)
|
|
37
|
+
- `polish_all_offsets=True` adds one stride-1 sweep over every complete window placement after normal cleanup, reducing the remaining local error at additional cost
|
|
38
|
+
- pass `mask` (a `(height, width)` bool array) to restrict which cells may be used — shaped atlases (circles, cut corners, a half-empty last row) work out of the box
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
|
|
42
|
+
- `grid_points`: `(n, 2)` float64 NumPy array of assigned destination points in original point order
|
|
43
|
+
- `assignment`: `(n,)` int64 NumPy array of destination-grid cell ids in original point order
|
|
44
|
+
- `(width, height)`: destination-grid size tuple
|
|
45
|
+
|
|
46
|
+
### `window_cleanup(points, initial_assignment, rows, cols, budget_seconds=None, ...)`
|
|
47
|
+
|
|
48
|
+
Improve any legal assignment with the native multiscale window cleanup kernel.
|
|
49
|
+
|
|
50
|
+
Key options:
|
|
51
|
+
|
|
52
|
+
- `budget_seconds=None` runs until converged (a full stride-1 round changes nothing)
|
|
53
|
+
- `strides=None` uses the automatic coarse-to-fine schedule; pass a list to override
|
|
54
|
+
- `window_size=6`
|
|
55
|
+
- `all_offsets=True` uses all `window_size ** 2` tiling offsets instead of the default four and is substantially slower
|
|
56
|
+
- `num_threads=None` to use `std::thread::hardware_concurrency()`
|
|
57
|
+
- `fixed_suffix_count` to keep a suffix of target cells fixed; `cell_mask` to mark which cells may be used at all
|
|
58
|
+
- `trace_rounds=True` to record per-round cost, elapsed time, and stride
|
|
59
|
+
|
|
60
|
+
Returns a dict with `assignment`, `rounds_completed`, `elapsed_s`, `final_cost`, and `converged`.
|
|
61
|
+
|
|
62
|
+
### `linear_sum_assignment(cost_matrix)`
|
|
63
|
+
|
|
64
|
+
Solve a dense square linear assignment problem exactly with the native C++ Jonker-Volgenant implementation.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
|
|
68
|
+
- `row_ind`: `int64` NumPy array of shape `(n,)`
|
|
69
|
+
- `col_ind`: `int64` NumPy array of shape `(n,)`
|
|
70
|
+
- `total_cost`: Python `float`
|
|
71
|
+
|
|
72
|
+
## More
|
|
73
|
+
|
|
74
|
+
- Source repository: https://github.com/kylemcdonald/shufflesnap
|
|
75
|
+
- Issue tracker: https://github.com/kylemcdonald/shufflesnap/issues
|
|
76
|
+
- Example scripts: https://github.com/kylemcdonald/shufflesnap/tree/main/examples
|
|
Binary file
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
import shufflesnap
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_meandering_points(n: int, seed: int = 0, margin: float = 0.03) -> np.ndarray:
|
|
12
|
+
rng = np.random.default_rng(seed)
|
|
13
|
+
steps = rng.normal(loc=0.0, scale=1.0, size=(n, 2))
|
|
14
|
+
points = np.cumsum(steps, axis=0)
|
|
15
|
+
mins = points.min(axis=0)
|
|
16
|
+
maxs = points.max(axis=0)
|
|
17
|
+
span = np.maximum(maxs - mins, np.finfo(np.float64).eps)
|
|
18
|
+
points = (points - mins) / span
|
|
19
|
+
points = margin + (1.0 - 2.0 * margin) * points
|
|
20
|
+
return np.asarray(points, dtype=np.float64)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def lab_to_srgb(points: np.ndarray) -> np.ndarray:
|
|
24
|
+
l = np.full(points.shape[0], 72.0, dtype=np.float64)
|
|
25
|
+
a = (points[:, 0] * 2.0 - 1.0) * 80.0
|
|
26
|
+
b = (points[:, 1] * 2.0 - 1.0) * 80.0
|
|
27
|
+
|
|
28
|
+
fy = (l + 16.0) / 116.0
|
|
29
|
+
fx = fy + a / 500.0
|
|
30
|
+
fz = fy - b / 200.0
|
|
31
|
+
|
|
32
|
+
epsilon = 216.0 / 24389.0
|
|
33
|
+
kappa = 24389.0 / 27.0
|
|
34
|
+
|
|
35
|
+
def invf(t: np.ndarray) -> np.ndarray:
|
|
36
|
+
t3 = t * t * t
|
|
37
|
+
return np.where(t3 > epsilon, t3, (116.0 * t - 16.0) / kappa)
|
|
38
|
+
|
|
39
|
+
x = 0.95047 * invf(fx)
|
|
40
|
+
y = invf(fy)
|
|
41
|
+
z = 1.08883 * invf(fz)
|
|
42
|
+
|
|
43
|
+
r_lin = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
|
|
44
|
+
g_lin = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
|
|
45
|
+
b_lin = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
|
|
46
|
+
rgb_lin = np.clip(np.column_stack([r_lin, g_lin, b_lin]), 0.0, 1.0)
|
|
47
|
+
|
|
48
|
+
threshold = 0.0031308
|
|
49
|
+
rgb = np.where(
|
|
50
|
+
rgb_lin <= threshold,
|
|
51
|
+
12.92 * rgb_lin,
|
|
52
|
+
1.055 * np.power(rgb_lin, 1.0 / 2.4) - 0.055,
|
|
53
|
+
)
|
|
54
|
+
return np.clip(rgb, 0.0, 1.0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> None:
|
|
58
|
+
points = make_meandering_points(32 * 32, seed=0)
|
|
59
|
+
grid_points, assignment, grid_size = shufflesnap.snap_to_grid(
|
|
60
|
+
points,
|
|
61
|
+
width=32,
|
|
62
|
+
height=32,
|
|
63
|
+
cleanup_seconds=0.5,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
interp = points + 0.8 * (grid_points - points)
|
|
67
|
+
colors = lab_to_srgb(points)
|
|
68
|
+
|
|
69
|
+
fig, ax = plt.subplots(figsize=(8, 8), dpi=150, facecolor="black")
|
|
70
|
+
ax.set_facecolor("black")
|
|
71
|
+
ax.scatter(interp[:, 0], interp[:, 1], s=1, c=colors, marker="s", linewidths=0)
|
|
72
|
+
ax.set_xlim(0.0, 1.0)
|
|
73
|
+
ax.set_ylim(0.0, 1.0)
|
|
74
|
+
ax.set_aspect("equal")
|
|
75
|
+
ax.set_xticks([])
|
|
76
|
+
ax.set_yticks([])
|
|
77
|
+
ax.set_title(f"shufflesnap snap_to_grid · grid={grid_size[0]}x{grid_size[1]}", color="white")
|
|
78
|
+
fig.tight_layout()
|
|
79
|
+
output = pathlib.Path(__file__).with_name("basic_usage_output.png")
|
|
80
|
+
fig.savefig(output, facecolor=fig.get_facecolor(), bbox_inches="tight")
|
|
81
|
+
plt.close(fig)
|
|
82
|
+
|
|
83
|
+
print("grid_size:", grid_size)
|
|
84
|
+
print("assignment shape:", assignment.shape)
|
|
85
|
+
print("first five assigned indices:", assignment[:5])
|
|
86
|
+
print("wrote image:", output)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|