Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions src/spatialdata/_core/operations/transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,11 @@
from functools import singledispatch
from typing import TYPE_CHECKING, Any, cast

import dask
import dask.array as da
import dask.dataframe as dd
import dask_image.ndinterp
import numpy as np
import pandas as pd
from dask.array.core import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
Expand DownExpand Up@@ -442,29 +443,51 @@ def _(
axes = get_axes_names(data)
arrays = []

# Workaround to prevent partition collaps and missing dependency problem for now.
# Dask's expression optimizer can collapse partitions at compute time, making the partition
# structure inside vs. outside a disable_dask_tune_optimization() context inconsistent. To avoid
# index-alignment failures (e.g. "cannot reindex on an axis with duplicate labels" from parquet
# files that each start their index at 0) and length-mismatch errors, we materialise the non-axis
# columns and compute the axis arrays inside a single context where the partition structure is
# stable, then do plain pandas operations and re-wrap with dd.from_delayed (not dd.from_pandas,
# which sorts by index and would scramble rows for non-monotonic or duplicate indices).
with disable_dask_tune_optimization() if data.npartitions > 1 else contextlib.nullcontext():
lengths = [len(part) for part in data.partitions]
for ax in axes:
# TODO We have to pass on the lengths explicitly as automatic determination with dask graph optimization
# leads to collaps of the partitions. However this causes a missing dependency problem, which for now is
# leads to collapse of the partitions. However this causes a missing dependency problem, which for now is
# prevented by setting the optimization to False when performing this operation.
arrays.append(data[ax].to_dask_array(lengths=[len(part) for part in data.partitions]).reshape(-1, 1))
arrays.append(data[ax].to_dask_array(lengths=lengths).reshape(-1, 1))

xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(len(data)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)
transformed = data.drop(columns=list(axes)).copy()
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs
xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(sum(lengths)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)

# Compute non-axis columns while the partition structure is still stable; preserves original index.
transformed_pd = data.drop(columns=list(axes)).compute()

for ax in axes:
indices = xtransformed["dim"] == ax
new_ax = xtransformed[:, indices]
# TODO: discuss with dask team
# This is not nice, but otherwise there is a problem with the joint graph of new_ax and transformed, causing
# a getattr missing dependency of dependent from_dask_array.
new_col = pd.Series(new_ax.data.flatten().compute(), index=transformed.index)
transformed[ax] = new_col
# Assigning a numpy array is positional (no index alignment), so the original index is preserved.
transformed_pd[ax] = new_ax.data.flatten().compute()

# Reconstruct as a dask DataFrame via delayed partitions so that:
# (a) row order matches the original (dd.from_pandas sorts by index, which scrambles rows for
# non-monotonic or duplicate indices such as those produced by multi-file parquet reads), and
# (b) the original index is preserved exactly.
offsets = np.cumsum([0] + lengths)
delayed_parts = [dask.delayed(transformed_pd.iloc[offsets[i] : offsets[i + 1]]) for i in range(len(lengths))]
transformed = dd.from_delayed(delayed_parts, meta=transformed_pd.iloc[:0])
# Preserve spatialdata_attrs (feature_key, instance_key, …) from the original element;
# dd.from_delayed starts with empty attrs so we must copy them explicitly.
for k, v in data.attrs.items():
if k != TRANSFORM_KEY:
transformed.attrs[k] = v
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs

old_transformations = cast(dict[str, Any], get_transformation(data, get_all=True))

Expand Down
48 changes: 48 additions & 0 deletions tests/core/operations/test_transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from pathlib import Path

import numpy as np
import pandas as pd
import pytest
from dask import config
from geopandas.testing import geom_almost_equals
Expand DownExpand Up@@ -590,6 +591,53 @@ def test_transform_elements_and_entire_spatial_data_object(full_sdata: SpatialDa
_ = full_sdata.transform_to_coordinate_system("my_space", maintain_positioning=maintain_positioning)


def test_transform_points_duplicate_index_gh1105(tmp_path: str):
"""Regression test for https://github.com/scverse/spatialdata/issues/1105.

Points loaded from multiple parquet files (e.g. Xenium transcripts) have a per-file 0-based
index, so the global dask DataFrame index has duplicate labels. The old implementation passed
``index=transformed.index`` to ``pd.Series``, which materialised the duplicate dask Index and
caused ``ValueError: cannot reindex on an axis with duplicate labels`` when assigning back.
"""
import dask.dataframe as dd

n_per_partition = 50
n_partitions = 4
rng = np.random.default_rng(0)

# Simulate multi-file parquet: each partition's index starts at 0
parts = [
pd.DataFrame(
{
"x": rng.random(n_per_partition).astype("float32"),
"y": rng.random(n_per_partition).astype("float32"),
"gene": [f"gene_{j}" for j in range(n_per_partition)],
}
)
for _ in range(n_partitions)
]
# test also the case of non-contiguous indices
for part in parts:
part.index = part.index.to_list()[:-1] + [100]
ddf = dd.from_map(lambda df: df, parts)
assert not ddf.index.compute().is_unique, "test setup: index must have duplicates"

scale_factor = 4
points = PointsModel.parse(ddf)
set_transformation(points, Scale([scale_factor, scale_factor], axes=("x", "y")), to_coordinate_system="global")

result = transform(points, to_coordinate_system="global")
result_pd = result.compute()

# Index must be preserved as-is (duplicate [0..49] × 4)
assert list(result_pd.index) == list(ddf.compute().index)
# Non-axis column must survive unchanged
assert list(result_pd["gene"]) == list(ddf.compute()["gene"])
# Axis values must be correctly scaled
expected_x = ddf.compute()["x"].values * scale_factor
np.testing.assert_allclose(result_pd["x"].values, expected_x, rtol=1e-5)


def test_transform_points_with_multiple_partitions(full_sdata: SpatialData, tmp_path: str):
tmpdir = Path(tmp_path) / "tmp.zarr"
points_memory = full_sdata["points_0"].compute()
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix transform points with duplicate indices by LucaMarconato · Pull Request #1129 · scverse/spatialdata · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions src/spatialdata/_core/operations/transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,11 @@
from functools import singledispatch
from typing import TYPE_CHECKING, Any, cast

import dask
import dask.array as da
import dask.dataframe as dd
import dask_image.ndinterp
import numpy as np
import pandas as pd
from dask.array.core import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
Expand DownExpand Up@@ -442,29 +443,51 @@ def _(
axes = get_axes_names(data)
arrays = []

# Workaround to prevent partition collaps and missing dependency problem for now.
# Dask's expression optimizer can collapse partitions at compute time, making the partition
# structure inside vs. outside a disable_dask_tune_optimization() context inconsistent. To avoid
# index-alignment failures (e.g. "cannot reindex on an axis with duplicate labels" from parquet
# files that each start their index at 0) and length-mismatch errors, we materialise the non-axis
# columns and compute the axis arrays inside a single context where the partition structure is
# stable, then do plain pandas operations and re-wrap with dd.from_delayed (not dd.from_pandas,
# which sorts by index and would scramble rows for non-monotonic or duplicate indices).
with disable_dask_tune_optimization() if data.npartitions > 1 else contextlib.nullcontext():
lengths = [len(part) for part in data.partitions]
for ax in axes:
# TODO We have to pass on the lengths explicitly as automatic determination with dask graph optimization
# leads to collaps of the partitions. However this causes a missing dependency problem, which for now is
# leads to collapse of the partitions. However this causes a missing dependency problem, which for now is
# prevented by setting the optimization to False when performing this operation.
arrays.append(data[ax].to_dask_array(lengths=[len(part) for part in data.partitions]).reshape(-1, 1))
arrays.append(data[ax].to_dask_array(lengths=lengths).reshape(-1, 1))

xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(len(data)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)
transformed = data.drop(columns=list(axes)).copy()
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs
xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(sum(lengths)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)

# Compute non-axis columns while the partition structure is still stable; preserves original index.
transformed_pd = data.drop(columns=list(axes)).compute()

for ax in axes:
indices = xtransformed["dim"] == ax
new_ax = xtransformed[:, indices]
# TODO: discuss with dask team
# This is not nice, but otherwise there is a problem with the joint graph of new_ax and transformed, causing
# a getattr missing dependency of dependent from_dask_array.
new_col = pd.Series(new_ax.data.flatten().compute(), index=transformed.index)
transformed[ax] = new_col
# Assigning a numpy array is positional (no index alignment), so the original index is preserved.
transformed_pd[ax] = new_ax.data.flatten().compute()

# Reconstruct as a dask DataFrame via delayed partitions so that:
# (a) row order matches the original (dd.from_pandas sorts by index, which scrambles rows for
# non-monotonic or duplicate indices such as those produced by multi-file parquet reads), and
# (b) the original index is preserved exactly.
offsets = np.cumsum([0] + lengths)
delayed_parts = [dask.delayed(transformed_pd.iloc[offsets[i] : offsets[i + 1]]) for i in range(len(lengths))]
transformed = dd.from_delayed(delayed_parts, meta=transformed_pd.iloc[:0])
# Preserve spatialdata_attrs (feature_key, instance_key, …) from the original element;
# dd.from_delayed starts with empty attrs so we must copy them explicitly.
for k, v in data.attrs.items():
if k != TRANSFORM_KEY:
transformed.attrs[k] = v
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs

old_transformations = cast(dict[str, Any], get_transformation(data, get_all=True))

Expand Down
48 changes: 48 additions & 0 deletions tests/core/operations/test_transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from pathlib import Path

import numpy as np
import pandas as pd
import pytest
from dask import config
from geopandas.testing import geom_almost_equals
Expand DownExpand Up@@ -590,6 +591,53 @@ def test_transform_elements_and_entire_spatial_data_object(full_sdata: SpatialDa
_ = full_sdata.transform_to_coordinate_system("my_space", maintain_positioning=maintain_positioning)


def test_transform_points_duplicate_index_gh1105(tmp_path: str):
"""Regression test for https://github.com/scverse/spatialdata/issues/1105.

Points loaded from multiple parquet files (e.g. Xenium transcripts) have a per-file 0-based
index, so the global dask DataFrame index has duplicate labels. The old implementation passed
``index=transformed.index`` to ``pd.Series``, which materialised the duplicate dask Index and
caused ``ValueError: cannot reindex on an axis with duplicate labels`` when assigning back.
"""
import dask.dataframe as dd

n_per_partition = 50
n_partitions = 4
rng = np.random.default_rng(0)

# Simulate multi-file parquet: each partition's index starts at 0
parts = [
pd.DataFrame(
{
"x": rng.random(n_per_partition).astype("float32"),
"y": rng.random(n_per_partition).astype("float32"),
"gene": [f"gene_{j}" for j in range(n_per_partition)],
}
)
for _ in range(n_partitions)
]
# test also the case of non-contiguous indices
for part in parts:
part.index = part.index.to_list()[:-1] + [100]
ddf = dd.from_map(lambda df: df, parts)
assert not ddf.index.compute().is_unique, "test setup: index must have duplicates"

scale_factor = 4
points = PointsModel.parse(ddf)
set_transformation(points, Scale([scale_factor, scale_factor], axes=("x", "y")), to_coordinate_system="global")

result = transform(points, to_coordinate_system="global")
result_pd = result.compute()

# Index must be preserved as-is (duplicate [0..49] × 4)
assert list(result_pd.index) == list(ddf.compute().index)
# Non-axis column must survive unchanged
assert list(result_pd["gene"]) == list(ddf.compute()["gene"])
# Axis values must be correctly scaled
expected_x = ddf.compute()["x"].values * scale_factor
np.testing.assert_allclose(result_pd["x"].values, expected_x, rtol=1e-5)


def test_transform_points_with_multiple_partitions(full_sdata: SpatialData, tmp_path: str):
tmpdir = Path(tmp_path) / "tmp.zarr"
points_memory = full_sdata["points_0"].compute()
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix transform points with duplicate indices by LucaMarconato · Pull Request #1129 · scverse/spatialdata · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions src/spatialdata/_core/operations/transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,11 @@
from functools import singledispatch
from typing import TYPE_CHECKING, Any, cast

import dask
import dask.array as da
import dask.dataframe as dd
import dask_image.ndinterp
import numpy as np
import pandas as pd
from dask.array.core import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
Expand DownExpand Up@@ -442,29 +443,51 @@ def _(
axes = get_axes_names(data)
arrays = []

# Workaround to prevent partition collaps and missing dependency problem for now.
# Dask's expression optimizer can collapse partitions at compute time, making the partition
# structure inside vs. outside a disable_dask_tune_optimization() context inconsistent. To avoid
# index-alignment failures (e.g. "cannot reindex on an axis with duplicate labels" from parquet
# files that each start their index at 0) and length-mismatch errors, we materialise the non-axis
# columns and compute the axis arrays inside a single context where the partition structure is
# stable, then do plain pandas operations and re-wrap with dd.from_delayed (not dd.from_pandas,
# which sorts by index and would scramble rows for non-monotonic or duplicate indices).
with disable_dask_tune_optimization() if data.npartitions > 1 else contextlib.nullcontext():
lengths = [len(part) for part in data.partitions]
for ax in axes:
# TODO We have to pass on the lengths explicitly as automatic determination with dask graph optimization
# leads to collaps of the partitions. However this causes a missing dependency problem, which for now is
# leads to collapse of the partitions. However this causes a missing dependency problem, which for now is
# prevented by setting the optimization to False when performing this operation.
arrays.append(data[ax].to_dask_array(lengths=[len(part) for part in data.partitions]).reshape(-1, 1))
arrays.append(data[ax].to_dask_array(lengths=lengths).reshape(-1, 1))

xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(len(data)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)
transformed = data.drop(columns=list(axes)).copy()
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs
xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(sum(lengths)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)

# Compute non-axis columns while the partition structure is still stable; preserves original index.
transformed_pd = data.drop(columns=list(axes)).compute()

for ax in axes:
indices = xtransformed["dim"] == ax
new_ax = xtransformed[:, indices]
# TODO: discuss with dask team
# This is not nice, but otherwise there is a problem with the joint graph of new_ax and transformed, causing
# a getattr missing dependency of dependent from_dask_array.
new_col = pd.Series(new_ax.data.flatten().compute(), index=transformed.index)
transformed[ax] = new_col
# Assigning a numpy array is positional (no index alignment), so the original index is preserved.
transformed_pd[ax] = new_ax.data.flatten().compute()

# Reconstruct as a dask DataFrame via delayed partitions so that:
# (a) row order matches the original (dd.from_pandas sorts by index, which scrambles rows for
# non-monotonic or duplicate indices such as those produced by multi-file parquet reads), and
# (b) the original index is preserved exactly.
offsets = np.cumsum([0] + lengths)
delayed_parts = [dask.delayed(transformed_pd.iloc[offsets[i] : offsets[i + 1]]) for i in range(len(lengths))]
transformed = dd.from_delayed(delayed_parts, meta=transformed_pd.iloc[:0])
# Preserve spatialdata_attrs (feature_key, instance_key, …) from the original element;
# dd.from_delayed starts with empty attrs so we must copy them explicitly.
for k, v in data.attrs.items():
if k != TRANSFORM_KEY:
transformed.attrs[k] = v
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs

old_transformations = cast(dict[str, Any], get_transformation(data, get_all=True))

Expand Down
48 changes: 48 additions & 0 deletions tests/core/operations/test_transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from pathlib import Path

import numpy as np
import pandas as pd
import pytest
from dask import config
from geopandas.testing import geom_almost_equals
Expand DownExpand Up@@ -590,6 +591,53 @@ def test_transform_elements_and_entire_spatial_data_object(full_sdata: SpatialDa
_ = full_sdata.transform_to_coordinate_system("my_space", maintain_positioning=maintain_positioning)


def test_transform_points_duplicate_index_gh1105(tmp_path: str):
"""Regression test for https://github.com/scverse/spatialdata/issues/1105.

Points loaded from multiple parquet files (e.g. Xenium transcripts) have a per-file 0-based
index, so the global dask DataFrame index has duplicate labels. The old implementation passed
``index=transformed.index`` to ``pd.Series``, which materialised the duplicate dask Index and
caused ``ValueError: cannot reindex on an axis with duplicate labels`` when assigning back.
"""
import dask.dataframe as dd

n_per_partition = 50
n_partitions = 4
rng = np.random.default_rng(0)

# Simulate multi-file parquet: each partition's index starts at 0
parts = [
pd.DataFrame(
{
"x": rng.random(n_per_partition).astype("float32"),
"y": rng.random(n_per_partition).astype("float32"),
"gene": [f"gene_{j}" for j in range(n_per_partition)],
}
)
for _ in range(n_partitions)
]
# test also the case of non-contiguous indices
for part in parts:
part.index = part.index.to_list()[:-1] + [100]
ddf = dd.from_map(lambda df: df, parts)
assert not ddf.index.compute().is_unique, "test setup: index must have duplicates"

scale_factor = 4
points = PointsModel.parse(ddf)
set_transformation(points, Scale([scale_factor, scale_factor], axes=("x", "y")), to_coordinate_system="global")

result = transform(points, to_coordinate_system="global")
result_pd = result.compute()

# Index must be preserved as-is (duplicate [0..49] × 4)
assert list(result_pd.index) == list(ddf.compute().index)
# Non-axis column must survive unchanged
assert list(result_pd["gene"]) == list(ddf.compute()["gene"])
# Axis values must be correctly scaled
expected_x = ddf.compute()["x"].values * scale_factor
np.testing.assert_allclose(result_pd["x"].values, expected_x, rtol=1e-5)


def test_transform_points_with_multiple_partitions(full_sdata: SpatialData, tmp_path: str):
tmpdir = Path(tmp_path) / "tmp.zarr"
points_memory = full_sdata["points_0"].compute()
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix transform points with duplicate indices by LucaMarconato · Pull Request #1129 · scverse/spatialdata · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions src/spatialdata/_core/operations/transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,11 @@
from functools import singledispatch
from typing import TYPE_CHECKING, Any, cast

import dask
import dask.array as da
import dask.dataframe as dd
import dask_image.ndinterp
import numpy as np
import pandas as pd
from dask.array.core import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
Expand DownExpand Up@@ -442,29 +443,51 @@ def _(
axes = get_axes_names(data)
arrays = []

# Workaround to prevent partition collaps and missing dependency problem for now.
# Dask's expression optimizer can collapse partitions at compute time, making the partition
# structure inside vs. outside a disable_dask_tune_optimization() context inconsistent. To avoid
# index-alignment failures (e.g. "cannot reindex on an axis with duplicate labels" from parquet
# files that each start their index at 0) and length-mismatch errors, we materialise the non-axis
# columns and compute the axis arrays inside a single context where the partition structure is
# stable, then do plain pandas operations and re-wrap with dd.from_delayed (not dd.from_pandas,
# which sorts by index and would scramble rows for non-monotonic or duplicate indices).
with disable_dask_tune_optimization() if data.npartitions > 1 else contextlib.nullcontext():
lengths = [len(part) for part in data.partitions]
for ax in axes:
# TODO We have to pass on the lengths explicitly as automatic determination with dask graph optimization
# leads to collaps of the partitions. However this causes a missing dependency problem, which for now is
# leads to collapse of the partitions. However this causes a missing dependency problem, which for now is
# prevented by setting the optimization to False when performing this operation.
arrays.append(data[ax].to_dask_array(lengths=[len(part) for part in data.partitions]).reshape(-1, 1))
arrays.append(data[ax].to_dask_array(lengths=lengths).reshape(-1, 1))

xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(len(data)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)
transformed = data.drop(columns=list(axes)).copy()
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs
xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(sum(lengths)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)

# Compute non-axis columns while the partition structure is still stable; preserves original index.
transformed_pd = data.drop(columns=list(axes)).compute()

for ax in axes:
indices = xtransformed["dim"] == ax
new_ax = xtransformed[:, indices]
# TODO: discuss with dask team
# This is not nice, but otherwise there is a problem with the joint graph of new_ax and transformed, causing
# a getattr missing dependency of dependent from_dask_array.
new_col = pd.Series(new_ax.data.flatten().compute(), index=transformed.index)
transformed[ax] = new_col
# Assigning a numpy array is positional (no index alignment), so the original index is preserved.
transformed_pd[ax] = new_ax.data.flatten().compute()

# Reconstruct as a dask DataFrame via delayed partitions so that:
# (a) row order matches the original (dd.from_pandas sorts by index, which scrambles rows for
# non-monotonic or duplicate indices such as those produced by multi-file parquet reads), and
# (b) the original index is preserved exactly.
offsets = np.cumsum([0] + lengths)
delayed_parts = [dask.delayed(transformed_pd.iloc[offsets[i] : offsets[i + 1]]) for i in range(len(lengths))]
transformed = dd.from_delayed(delayed_parts, meta=transformed_pd.iloc[:0])
# Preserve spatialdata_attrs (feature_key, instance_key, …) from the original element;
# dd.from_delayed starts with empty attrs so we must copy them explicitly.
for k, v in data.attrs.items():
if k != TRANSFORM_KEY:
transformed.attrs[k] = v
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs

old_transformations = cast(dict[str, Any], get_transformation(data, get_all=True))

Expand Down
48 changes: 48 additions & 0 deletions tests/core/operations/test_transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from pathlib import Path

import numpy as np
import pandas as pd
import pytest
from dask import config
from geopandas.testing import geom_almost_equals
Expand DownExpand Up@@ -590,6 +591,53 @@ def test_transform_elements_and_entire_spatial_data_object(full_sdata: SpatialDa
_ = full_sdata.transform_to_coordinate_system("my_space", maintain_positioning=maintain_positioning)


def test_transform_points_duplicate_index_gh1105(tmp_path: str):
"""Regression test for https://github.com/scverse/spatialdata/issues/1105.

Points loaded from multiple parquet files (e.g. Xenium transcripts) have a per-file 0-based
index, so the global dask DataFrame index has duplicate labels. The old implementation passed
``index=transformed.index`` to ``pd.Series``, which materialised the duplicate dask Index and
caused ``ValueError: cannot reindex on an axis with duplicate labels`` when assigning back.
"""
import dask.dataframe as dd

n_per_partition = 50
n_partitions = 4
rng = np.random.default_rng(0)

# Simulate multi-file parquet: each partition's index starts at 0
parts = [
pd.DataFrame(
{
"x": rng.random(n_per_partition).astype("float32"),
"y": rng.random(n_per_partition).astype("float32"),
"gene": [f"gene_{j}" for j in range(n_per_partition)],
}
)
for _ in range(n_partitions)
]
# test also the case of non-contiguous indices
for part in parts:
part.index = part.index.to_list()[:-1] + [100]
ddf = dd.from_map(lambda df: df, parts)
assert not ddf.index.compute().is_unique, "test setup: index must have duplicates"

scale_factor = 4
points = PointsModel.parse(ddf)
set_transformation(points, Scale([scale_factor, scale_factor], axes=("x", "y")), to_coordinate_system="global")

result = transform(points, to_coordinate_system="global")
result_pd = result.compute()

# Index must be preserved as-is (duplicate [0..49] × 4)
assert list(result_pd.index) == list(ddf.compute().index)
# Non-axis column must survive unchanged
assert list(result_pd["gene"]) == list(ddf.compute()["gene"])
# Axis values must be correctly scaled
expected_x = ddf.compute()["x"].values * scale_factor
np.testing.assert_allclose(result_pd["x"].values, expected_x, rtol=1e-5)


def test_transform_points_with_multiple_partitions(full_sdata: SpatialData, tmp_path: str):
tmpdir = Path(tmp_path) / "tmp.zarr"
points_memory = full_sdata["points_0"].compute()
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix transform points with duplicate indices by LucaMarconato · Pull Request #1129 · scverse/spatialdata · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions src/spatialdata/_core/operations/transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,11 @@
from functools import singledispatch
from typing import TYPE_CHECKING, Any, cast

import dask
import dask.array as da
import dask.dataframe as dd
import dask_image.ndinterp
import numpy as np
import pandas as pd
from dask.array.core import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
Expand DownExpand Up@@ -442,29 +443,51 @@ def _(
axes = get_axes_names(data)
arrays = []

# Workaround to prevent partition collaps and missing dependency problem for now.
# Dask's expression optimizer can collapse partitions at compute time, making the partition
# structure inside vs. outside a disable_dask_tune_optimization() context inconsistent. To avoid
# index-alignment failures (e.g. "cannot reindex on an axis with duplicate labels" from parquet
# files that each start their index at 0) and length-mismatch errors, we materialise the non-axis
# columns and compute the axis arrays inside a single context where the partition structure is
# stable, then do plain pandas operations and re-wrap with dd.from_delayed (not dd.from_pandas,
# which sorts by index and would scramble rows for non-monotonic or duplicate indices).
with disable_dask_tune_optimization() if data.npartitions > 1 else contextlib.nullcontext():
lengths = [len(part) for part in data.partitions]
for ax in axes:
# TODO We have to pass on the lengths explicitly as automatic determination with dask graph optimization
# leads to collaps of the partitions. However this causes a missing dependency problem, which for now is
# leads to collapse of the partitions. However this causes a missing dependency problem, which for now is
# prevented by setting the optimization to False when performing this operation.
arrays.append(data[ax].to_dask_array(lengths=[len(part) for part in data.partitions]).reshape(-1, 1))
arrays.append(data[ax].to_dask_array(lengths=lengths).reshape(-1, 1))

xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(len(data)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)
transformed = data.drop(columns=list(axes)).copy()
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs
xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(sum(lengths)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)

# Compute non-axis columns while the partition structure is still stable; preserves original index.
transformed_pd = data.drop(columns=list(axes)).compute()

for ax in axes:
indices = xtransformed["dim"] == ax
new_ax = xtransformed[:, indices]
# TODO: discuss with dask team
# This is not nice, but otherwise there is a problem with the joint graph of new_ax and transformed, causing
# a getattr missing dependency of dependent from_dask_array.
new_col = pd.Series(new_ax.data.flatten().compute(), index=transformed.index)
transformed[ax] = new_col
# Assigning a numpy array is positional (no index alignment), so the original index is preserved.
transformed_pd[ax] = new_ax.data.flatten().compute()

# Reconstruct as a dask DataFrame via delayed partitions so that:
# (a) row order matches the original (dd.from_pandas sorts by index, which scrambles rows for
# non-monotonic or duplicate indices such as those produced by multi-file parquet reads), and
# (b) the original index is preserved exactly.
offsets = np.cumsum([0] + lengths)
delayed_parts = [dask.delayed(transformed_pd.iloc[offsets[i] : offsets[i + 1]]) for i in range(len(lengths))]
transformed = dd.from_delayed(delayed_parts, meta=transformed_pd.iloc[:0])
# Preserve spatialdata_attrs (feature_key, instance_key, …) from the original element;
# dd.from_delayed starts with empty attrs so we must copy them explicitly.
for k, v in data.attrs.items():
if k != TRANSFORM_KEY:
transformed.attrs[k] = v
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs

old_transformations = cast(dict[str, Any], get_transformation(data, get_all=True))

Expand Down
48 changes: 48 additions & 0 deletions tests/core/operations/test_transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from pathlib import Path

import numpy as np
import pandas as pd
import pytest
from dask import config
from geopandas.testing import geom_almost_equals
Expand DownExpand Up@@ -590,6 +591,53 @@ def test_transform_elements_and_entire_spatial_data_object(full_sdata: SpatialDa
_ = full_sdata.transform_to_coordinate_system("my_space", maintain_positioning=maintain_positioning)


def test_transform_points_duplicate_index_gh1105(tmp_path: str):
"""Regression test for https://github.com/scverse/spatialdata/issues/1105.

Points loaded from multiple parquet files (e.g. Xenium transcripts) have a per-file 0-based
index, so the global dask DataFrame index has duplicate labels. The old implementation passed
``index=transformed.index`` to ``pd.Series``, which materialised the duplicate dask Index and
caused ``ValueError: cannot reindex on an axis with duplicate labels`` when assigning back.
"""
import dask.dataframe as dd

n_per_partition = 50
n_partitions = 4
rng = np.random.default_rng(0)

# Simulate multi-file parquet: each partition's index starts at 0
parts = [
pd.DataFrame(
{
"x": rng.random(n_per_partition).astype("float32"),
"y": rng.random(n_per_partition).astype("float32"),
"gene": [f"gene_{j}" for j in range(n_per_partition)],
}
)
for _ in range(n_partitions)
]
# test also the case of non-contiguous indices
for part in parts:
part.index = part.index.to_list()[:-1] + [100]
ddf = dd.from_map(lambda df: df, parts)
assert not ddf.index.compute().is_unique, "test setup: index must have duplicates"

scale_factor = 4
points = PointsModel.parse(ddf)
set_transformation(points, Scale([scale_factor, scale_factor], axes=("x", "y")), to_coordinate_system="global")

result = transform(points, to_coordinate_system="global")
result_pd = result.compute()

# Index must be preserved as-is (duplicate [0..49] × 4)
assert list(result_pd.index) == list(ddf.compute().index)
# Non-axis column must survive unchanged
assert list(result_pd["gene"]) == list(ddf.compute()["gene"])
# Axis values must be correctly scaled
expected_x = ddf.compute()["x"].values * scale_factor
np.testing.assert_allclose(result_pd["x"].values, expected_x, rtol=1e-5)


def test_transform_points_with_multiple_partitions(full_sdata: SpatialData, tmp_path: str):
tmpdir = Path(tmp_path) / "tmp.zarr"
points_memory = full_sdata["points_0"].compute()
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix transform points with duplicate indices by LucaMarconato · Pull Request #1129 · scverse/spatialdata · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions src/spatialdata/_core/operations/transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,11 @@
from functools import singledispatch
from typing import TYPE_CHECKING, Any, cast

import dask
import dask.array as da
import dask.dataframe as dd
import dask_image.ndinterp
import numpy as np
import pandas as pd
from dask.array.core import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
Expand DownExpand Up@@ -442,29 +443,51 @@ def _(
axes = get_axes_names(data)
arrays = []

# Workaround to prevent partition collaps and missing dependency problem for now.
# Dask's expression optimizer can collapse partitions at compute time, making the partition
# structure inside vs. outside a disable_dask_tune_optimization() context inconsistent. To avoid
# index-alignment failures (e.g. "cannot reindex on an axis with duplicate labels" from parquet
# files that each start their index at 0) and length-mismatch errors, we materialise the non-axis
# columns and compute the axis arrays inside a single context where the partition structure is
# stable, then do plain pandas operations and re-wrap with dd.from_delayed (not dd.from_pandas,
# which sorts by index and would scramble rows for non-monotonic or duplicate indices).
with disable_dask_tune_optimization() if data.npartitions > 1 else contextlib.nullcontext():
lengths = [len(part) for part in data.partitions]
for ax in axes:
# TODO We have to pass on the lengths explicitly as automatic determination with dask graph optimization
# leads to collaps of the partitions. However this causes a missing dependency problem, which for now is
# leads to collapse of the partitions. However this causes a missing dependency problem, which for now is
# prevented by setting the optimization to False when performing this operation.
arrays.append(data[ax].to_dask_array(lengths=[len(part) for part in data.partitions]).reshape(-1, 1))
arrays.append(data[ax].to_dask_array(lengths=lengths).reshape(-1, 1))

xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(len(data)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)
transformed = data.drop(columns=list(axes)).copy()
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs
xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(sum(lengths)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)

# Compute non-axis columns while the partition structure is still stable; preserves original index.
transformed_pd = data.drop(columns=list(axes)).compute()

for ax in axes:
indices = xtransformed["dim"] == ax
new_ax = xtransformed[:, indices]
# TODO: discuss with dask team
# This is not nice, but otherwise there is a problem with the joint graph of new_ax and transformed, causing
# a getattr missing dependency of dependent from_dask_array.
new_col = pd.Series(new_ax.data.flatten().compute(), index=transformed.index)
transformed[ax] = new_col
# Assigning a numpy array is positional (no index alignment), so the original index is preserved.
transformed_pd[ax] = new_ax.data.flatten().compute()

# Reconstruct as a dask DataFrame via delayed partitions so that:
# (a) row order matches the original (dd.from_pandas sorts by index, which scrambles rows for
# non-monotonic or duplicate indices such as those produced by multi-file parquet reads), and
# (b) the original index is preserved exactly.
offsets = np.cumsum([0] + lengths)
delayed_parts = [dask.delayed(transformed_pd.iloc[offsets[i] : offsets[i + 1]]) for i in range(len(lengths))]
transformed = dd.from_delayed(delayed_parts, meta=transformed_pd.iloc[:0])
# Preserve spatialdata_attrs (feature_key, instance_key, …) from the original element;
# dd.from_delayed starts with empty attrs so we must copy them explicitly.
for k, v in data.attrs.items():
if k != TRANSFORM_KEY:
transformed.attrs[k] = v
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs

old_transformations = cast(dict[str, Any], get_transformation(data, get_all=True))

Expand Down
48 changes: 48 additions & 0 deletions tests/core/operations/test_transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from pathlib import Path

import numpy as np
import pandas as pd
import pytest
from dask import config
from geopandas.testing import geom_almost_equals
Expand DownExpand Up@@ -590,6 +591,53 @@ def test_transform_elements_and_entire_spatial_data_object(full_sdata: SpatialDa
_ = full_sdata.transform_to_coordinate_system("my_space", maintain_positioning=maintain_positioning)


def test_transform_points_duplicate_index_gh1105(tmp_path: str):
"""Regression test for https://github.com/scverse/spatialdata/issues/1105.

Points loaded from multiple parquet files (e.g. Xenium transcripts) have a per-file 0-based
index, so the global dask DataFrame index has duplicate labels. The old implementation passed
``index=transformed.index`` to ``pd.Series``, which materialised the duplicate dask Index and
caused ``ValueError: cannot reindex on an axis with duplicate labels`` when assigning back.
"""
import dask.dataframe as dd

n_per_partition = 50
n_partitions = 4
rng = np.random.default_rng(0)

# Simulate multi-file parquet: each partition's index starts at 0
parts = [
pd.DataFrame(
{
"x": rng.random(n_per_partition).astype("float32"),
"y": rng.random(n_per_partition).astype("float32"),
"gene": [f"gene_{j}" for j in range(n_per_partition)],
}
)
for _ in range(n_partitions)
]
# test also the case of non-contiguous indices
for part in parts:
part.index = part.index.to_list()[:-1] + [100]
ddf = dd.from_map(lambda df: df, parts)
assert not ddf.index.compute().is_unique, "test setup: index must have duplicates"

scale_factor = 4
points = PointsModel.parse(ddf)
set_transformation(points, Scale([scale_factor, scale_factor], axes=("x", "y")), to_coordinate_system="global")

result = transform(points, to_coordinate_system="global")
result_pd = result.compute()

# Index must be preserved as-is (duplicate [0..49] × 4)
assert list(result_pd.index) == list(ddf.compute().index)
# Non-axis column must survive unchanged
assert list(result_pd["gene"]) == list(ddf.compute()["gene"])
# Axis values must be correctly scaled
expected_x = ddf.compute()["x"].values * scale_factor
np.testing.assert_allclose(result_pd["x"].values, expected_x, rtol=1e-5)


def test_transform_points_with_multiple_partitions(full_sdata: SpatialData, tmp_path: str):
tmpdir = Path(tmp_path) / "tmp.zarr"
points_memory = full_sdata["points_0"].compute()
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix transform points with duplicate indices by LucaMarconato · Pull Request #1129 · scverse/spatialdata · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions src/spatialdata/_core/operations/transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,11 @@
from functools import singledispatch
from typing import TYPE_CHECKING, Any, cast

import dask
import dask.array as da
import dask.dataframe as dd
import dask_image.ndinterp
import numpy as np
import pandas as pd
from dask.array.core import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
Expand DownExpand Up@@ -442,29 +443,51 @@ def _(
axes = get_axes_names(data)
arrays = []

# Workaround to prevent partition collaps and missing dependency problem for now.
# Dask's expression optimizer can collapse partitions at compute time, making the partition
# structure inside vs. outside a disable_dask_tune_optimization() context inconsistent. To avoid
# index-alignment failures (e.g. "cannot reindex on an axis with duplicate labels" from parquet
# files that each start their index at 0) and length-mismatch errors, we materialise the non-axis
# columns and compute the axis arrays inside a single context where the partition structure is
# stable, then do plain pandas operations and re-wrap with dd.from_delayed (not dd.from_pandas,
# which sorts by index and would scramble rows for non-monotonic or duplicate indices).
with disable_dask_tune_optimization() if data.npartitions > 1 else contextlib.nullcontext():
lengths = [len(part) for part in data.partitions]
for ax in axes:
# TODO We have to pass on the lengths explicitly as automatic determination with dask graph optimization
# leads to collaps of the partitions. However this causes a missing dependency problem, which for now is
# leads to collapse of the partitions. However this causes a missing dependency problem, which for now is
# prevented by setting the optimization to False when performing this operation.
arrays.append(data[ax].to_dask_array(lengths=[len(part) for part in data.partitions]).reshape(-1, 1))
arrays.append(data[ax].to_dask_array(lengths=lengths).reshape(-1, 1))

xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(len(data)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)
transformed = data.drop(columns=list(axes)).copy()
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs
xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(sum(lengths)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)

# Compute non-axis columns while the partition structure is still stable; preserves original index.
transformed_pd = data.drop(columns=list(axes)).compute()

for ax in axes:
indices = xtransformed["dim"] == ax
new_ax = xtransformed[:, indices]
# TODO: discuss with dask team
# This is not nice, but otherwise there is a problem with the joint graph of new_ax and transformed, causing
# a getattr missing dependency of dependent from_dask_array.
new_col = pd.Series(new_ax.data.flatten().compute(), index=transformed.index)
transformed[ax] = new_col
# Assigning a numpy array is positional (no index alignment), so the original index is preserved.
transformed_pd[ax] = new_ax.data.flatten().compute()

# Reconstruct as a dask DataFrame via delayed partitions so that:
# (a) row order matches the original (dd.from_pandas sorts by index, which scrambles rows for
# non-monotonic or duplicate indices such as those produced by multi-file parquet reads), and
# (b) the original index is preserved exactly.
offsets = np.cumsum([0] + lengths)
delayed_parts = [dask.delayed(transformed_pd.iloc[offsets[i] : offsets[i + 1]]) for i in range(len(lengths))]
transformed = dd.from_delayed(delayed_parts, meta=transformed_pd.iloc[:0])
# Preserve spatialdata_attrs (feature_key, instance_key, …) from the original element;
# dd.from_delayed starts with empty attrs so we must copy them explicitly.
for k, v in data.attrs.items():
if k != TRANSFORM_KEY:
transformed.attrs[k] = v
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs

old_transformations = cast(dict[str, Any], get_transformation(data, get_all=True))

Expand Down
48 changes: 48 additions & 0 deletions tests/core/operations/test_transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from pathlib import Path

import numpy as np
import pandas as pd
import pytest
from dask import config
from geopandas.testing import geom_almost_equals
Expand DownExpand Up@@ -590,6 +591,53 @@ def test_transform_elements_and_entire_spatial_data_object(full_sdata: SpatialDa
_ = full_sdata.transform_to_coordinate_system("my_space", maintain_positioning=maintain_positioning)


def test_transform_points_duplicate_index_gh1105(tmp_path: str):
"""Regression test for https://github.com/scverse/spatialdata/issues/1105.

Points loaded from multiple parquet files (e.g. Xenium transcripts) have a per-file 0-based
index, so the global dask DataFrame index has duplicate labels. The old implementation passed
``index=transformed.index`` to ``pd.Series``, which materialised the duplicate dask Index and
caused ``ValueError: cannot reindex on an axis with duplicate labels`` when assigning back.
"""
import dask.dataframe as dd

n_per_partition = 50
n_partitions = 4
rng = np.random.default_rng(0)

# Simulate multi-file parquet: each partition's index starts at 0
parts = [
pd.DataFrame(
{
"x": rng.random(n_per_partition).astype("float32"),
"y": rng.random(n_per_partition).astype("float32"),
"gene": [f"gene_{j}" for j in range(n_per_partition)],
}
)
for _ in range(n_partitions)
]
# test also the case of non-contiguous indices
for part in parts:
part.index = part.index.to_list()[:-1] + [100]
ddf = dd.from_map(lambda df: df, parts)
assert not ddf.index.compute().is_unique, "test setup: index must have duplicates"

scale_factor = 4
points = PointsModel.parse(ddf)
set_transformation(points, Scale([scale_factor, scale_factor], axes=("x", "y")), to_coordinate_system="global")

result = transform(points, to_coordinate_system="global")
result_pd = result.compute()

# Index must be preserved as-is (duplicate [0..49] × 4)
assert list(result_pd.index) == list(ddf.compute().index)
# Non-axis column must survive unchanged
assert list(result_pd["gene"]) == list(ddf.compute()["gene"])
# Axis values must be correctly scaled
expected_x = ddf.compute()["x"].values * scale_factor
np.testing.assert_allclose(result_pd["x"].values, expected_x, rtol=1e-5)


def test_transform_points_with_multiple_partitions(full_sdata: SpatialData, tmp_path: str):
tmpdir = Path(tmp_path) / "tmp.zarr"
points_memory = full_sdata["points_0"].compute()
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix transform points with duplicate indices by LucaMarconato · Pull Request #1129 · scverse/spatialdata · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions src/spatialdata/_core/operations/transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,11 @@
from functools import singledispatch
from typing import TYPE_CHECKING, Any, cast

import dask
import dask.array as da
import dask.dataframe as dd
import dask_image.ndinterp
import numpy as np
import pandas as pd
from dask.array.core import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
Expand DownExpand Up@@ -442,29 +443,51 @@ def _(
axes = get_axes_names(data)
arrays = []

# Workaround to prevent partition collaps and missing dependency problem for now.
# Dask's expression optimizer can collapse partitions at compute time, making the partition
# structure inside vs. outside a disable_dask_tune_optimization() context inconsistent. To avoid
# index-alignment failures (e.g. "cannot reindex on an axis with duplicate labels" from parquet
# files that each start their index at 0) and length-mismatch errors, we materialise the non-axis
# columns and compute the axis arrays inside a single context where the partition structure is
# stable, then do plain pandas operations and re-wrap with dd.from_delayed (not dd.from_pandas,
# which sorts by index and would scramble rows for non-monotonic or duplicate indices).
with disable_dask_tune_optimization() if data.npartitions > 1 else contextlib.nullcontext():
lengths = [len(part) for part in data.partitions]
for ax in axes:
# TODO We have to pass on the lengths explicitly as automatic determination with dask graph optimization
# leads to collaps of the partitions. However this causes a missing dependency problem, which for now is
# leads to collapse of the partitions. However this causes a missing dependency problem, which for now is
# prevented by setting the optimization to False when performing this operation.
arrays.append(data[ax].to_dask_array(lengths=[len(part) for part in data.partitions]).reshape(-1, 1))
arrays.append(data[ax].to_dask_array(lengths=lengths).reshape(-1, 1))

xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(len(data)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)
transformed = data.drop(columns=list(axes)).copy()
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs
xdata = DataArray(da.concatenate(arrays, axis=1), coords={"points": range(sum(lengths)), "dim": list(axes)})
xtransformed = transformation._transform_coordinates(xdata)

# Compute non-axis columns while the partition structure is still stable; preserves original index.
transformed_pd = data.drop(columns=list(axes)).compute()

for ax in axes:
indices = xtransformed["dim"] == ax
new_ax = xtransformed[:, indices]
# TODO: discuss with dask team
# This is not nice, but otherwise there is a problem with the joint graph of new_ax and transformed, causing
# a getattr missing dependency of dependent from_dask_array.
new_col = pd.Series(new_ax.data.flatten().compute(), index=transformed.index)
transformed[ax] = new_col
# Assigning a numpy array is positional (no index alignment), so the original index is preserved.
transformed_pd[ax] = new_ax.data.flatten().compute()

# Reconstruct as a dask DataFrame via delayed partitions so that:
# (a) row order matches the original (dd.from_pandas sorts by index, which scrambles rows for
# non-monotonic or duplicate indices such as those produced by multi-file parquet reads), and
# (b) the original index is preserved exactly.
offsets = np.cumsum([0] + lengths)
delayed_parts = [dask.delayed(transformed_pd.iloc[offsets[i] : offsets[i + 1]]) for i in range(len(lengths))]
transformed = dd.from_delayed(delayed_parts, meta=transformed_pd.iloc[:0])
# Preserve spatialdata_attrs (feature_key, instance_key, …) from the original element;
# dd.from_delayed starts with empty attrs so we must copy them explicitly.
for k, v in data.attrs.items():
if k != TRANSFORM_KEY:
transformed.attrs[k] = v
# dummy transformation that will be replaced by _adjust_transformation()
default_cs = {DEFAULT_COORDINATE_SYSTEM: Identity()}
transformed.attrs[TRANSFORM_KEY] = default_cs

old_transformations = cast(dict[str, Any], get_transformation(data, get_all=True))

Expand Down
48 changes: 48 additions & 0 deletions tests/core/operations/test_transform.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
from pathlib import Path

import numpy as np
import pandas as pd
import pytest
from dask import config
from geopandas.testing import geom_almost_equals
Expand DownExpand Up@@ -590,6 +591,53 @@ def test_transform_elements_and_entire_spatial_data_object(full_sdata: SpatialDa
_ = full_sdata.transform_to_coordinate_system("my_space", maintain_positioning=maintain_positioning)


def test_transform_points_duplicate_index_gh1105(tmp_path: str):
"""Regression test for https://github.com/scverse/spatialdata/issues/1105.

Points loaded from multiple parquet files (e.g. Xenium transcripts) have a per-file 0-based
index, so the global dask DataFrame index has duplicate labels. The old implementation passed
``index=transformed.index`` to ``pd.Series``, which materialised the duplicate dask Index and
caused ``ValueError: cannot reindex on an axis with duplicate labels`` when assigning back.
"""
import dask.dataframe as dd

n_per_partition = 50
n_partitions = 4
rng = np.random.default_rng(0)

# Simulate multi-file parquet: each partition's index starts at 0
parts = [
pd.DataFrame(
{
"x": rng.random(n_per_partition).astype("float32"),
"y": rng.random(n_per_partition).astype("float32"),
"gene": [f"gene_{j}" for j in range(n_per_partition)],
}
)
for _ in range(n_partitions)
]
# test also the case of non-contiguous indices
for part in parts:
part.index = part.index.to_list()[:-1] + [100]
ddf = dd.from_map(lambda df: df, parts)
assert not ddf.index.compute().is_unique, "test setup: index must have duplicates"

scale_factor = 4
points = PointsModel.parse(ddf)
set_transformation(points, Scale([scale_factor, scale_factor], axes=("x", "y")), to_coordinate_system="global")

result = transform(points, to_coordinate_system="global")
result_pd = result.compute()

# Index must be preserved as-is (duplicate [0..49] × 4)
assert list(result_pd.index) == list(ddf.compute().index)
# Non-axis column must survive unchanged
assert list(result_pd["gene"]) == list(ddf.compute()["gene"])
# Axis values must be correctly scaled
expected_x = ddf.compute()["x"].values * scale_factor
np.testing.assert_allclose(result_pd["x"].values, expected_x, rtol=1e-5)


def test_transform_points_with_multiple_partitions(full_sdata: SpatialData, tmp_path: str):
tmpdir = Path(tmp_path) / "tmp.zarr"
points_memory = full_sdata["points_0"].compute()
Expand Down
Loading