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.
@@ -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,12 @@
1
+ .DS_Store
2
+ .pytest_cache/
3
+ .scikit-build/
4
+ .venv*/
5
+ build/
6
+ dist/
7
+ wheelhouse/
8
+
9
+ __pycache__/
10
+ *.py[cod]
11
+
12
+ examples/basic_usage_output.png
@@ -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
+ ![shufflesnap triptych showcase](assets/showcase_triptych_512.png)
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
@@ -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()