From a5c3e9162426e94b8ad7db715ba160c7ef458819 Mon Sep 17 00:00:00 2001 From: Jon Crall Date: Fri, 23 Jan 2026 18:32:28 -0500 Subject: [PATCH 1/7] Optimize optimize() traversal and add tests --- CHANGELOG.md | 3 + delayed_image/delayed_base.py | 14 +- delayed_image/delayed_base.pyi | 9 +- delayed_image/delayed_leafs.py | 9 +- delayed_image/delayed_leafs.pyi | 3 +- delayed_image/delayed_nodes.py | 357 +++++++++++++++++++++----------- delayed_image/delayed_nodes.pyi | 14 +- tests/test_optimize_context.py | 127 ++++++++++++ 8 files changed, 409 insertions(+), 127 deletions(-) create mode 100644 tests/test_optimize_context.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6906bb3..f56e63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## Version 0.4.6 - Unreleased +### Performance +* Improve optimize() performance via per-call memoization, reduced allocations, and fixed-point rewrite loops; no behavior change intended. + ### Fix * Handle case when input sensorchan strings are string subclasses. * Fix issue where lazy warps did not respect explicitly given dsize arguments diff --git a/delayed_image/delayed_base.py b/delayed_image/delayed_base.py index 5abae10..54c80a1 100644 --- a/delayed_image/delayed_base.py +++ b/delayed_image/delayed_base.py @@ -13,6 +13,18 @@ USE_SLOTS = True +# Per-call optimization context +class OptimizeContext: + """ + Holds per-call optimization state to avoid repeated work. + """ + if USE_SLOTS: + __slots__ = ('memo',) + + def __init__(self): + self.memo = {} + + # from kwcoco.util.util_monkey import Reloadable # NOQA # @Reloadable.developing # NOQA class DelayedOperation: @@ -385,7 +397,7 @@ def finalize(self, prepare=True, optimize=True, **kwargs): # final = np.asanyarray(final) # does not work with xarray return final - def optimize(self): + def optimize(self, ctx=None): """ Returns: DelayedOperation diff --git a/delayed_image/delayed_base.pyi b/delayed_image/delayed_base.pyi index ae741da..c723a0a 100644 --- a/delayed_image/delayed_base.pyi +++ b/delayed_image/delayed_base.pyi @@ -9,6 +9,13 @@ from _typeshed import Incomplete from collections.abc import Generator +class OptimizeContext: + memo: Dict[int, 'DelayedOperation'] + + def __init__(self) -> None: + ... + + class DelayedOperation(ub.NiceRepr): meta: Incomplete @@ -57,7 +64,7 @@ class DelayedOperation(ub.NiceRepr): **kwargs) -> ArrayLike: ... - def optimize(self) -> DelayedOperation: + def optimize(self, ctx: OptimizeContext | None = None) -> DelayedOperation: ... diff --git a/delayed_image/delayed_leafs.py b/delayed_image/delayed_leafs.py index 01b4788..c6cb5dd 100644 --- a/delayed_image/delayed_leafs.py +++ b/delayed_image/delayed_leafs.py @@ -30,9 +30,16 @@ def get_transform_from_leaf(self): """ return kwimage.Affine.eye() - def optimize(self): + def optimize(self, ctx=None): + if ctx is None: + ctx = delayed_base.OptimizeContext() + memo = ctx.memo + node_id = id(self) + if node_id in memo: + return memo[node_id] if TRACE_OPTIMIZE: self._opt_logs.append('optimize DelayedImageLeaf') + memo[node_id] = self return self diff --git a/delayed_image/delayed_leafs.pyi b/delayed_image/delayed_leafs.pyi index 719975c..e7a7269 100644 --- a/delayed_image/delayed_leafs.pyi +++ b/delayed_image/delayed_leafs.pyi @@ -3,6 +3,7 @@ from os import PathLike from typing import Tuple from _typeshed import Incomplete from delayed_image.delayed_nodes import DelayedImage +from delayed_image.delayed_base import OptimizeContext from delayed_image.channel_spec import FusedChannelSpec @@ -14,7 +15,7 @@ class DelayedImageLeaf(DelayedImage): def get_transform_from_leaf(self) -> kwimage.Affine: ... - def optimize(self): + def optimize(self, ctx: OptimizeContext | None = None): ... diff --git a/delayed_image/delayed_nodes.py b/delayed_image/delayed_nodes.py index b3a986c..8b7bb54 100644 --- a/delayed_image/delayed_nodes.py +++ b/delayed_image/delayed_nodes.py @@ -658,16 +658,26 @@ def _finalize(self): final = np.concatenate(stack, axis=2) return final - def optimize(self): + def optimize(self, ctx=None): """ Returns: DelayedImage """ - new_parts = [part.optimize() for part in self.parts] - kw = ub.dict_isect(self.meta, ['dsize']) - new = self.__class__(new_parts, **kw) + if ctx is None: + ctx = delayed_base.OptimizeContext() + memo = ctx.memo + node_id = id(self) + if node_id in memo: + return memo[node_id] + new_parts = [part.optimize(ctx) for part in self.parts] + if all(p is o for p, o in zip(new_parts, self.parts)): + new = self + else: + kw = ub.dict_isect(self.meta, ['dsize']) + new = self.__class__(new_parts, **kw) if TRACE_OPTIMIZE: new._opt_logs.append('optimize DelayedChannelConcat') + memo[node_id] = new return new def take_channels(self, channels, missing_channel_policy='return_nan'): @@ -1452,14 +1462,25 @@ def _finalize(self): final = xr.DataArray(subfinal, dims=('y', 'x', 'c'), coords=coords) return final - def optimize(self): + def optimize(self, ctx=None): """ Returns: DelayedImage """ - new = self.subdata.optimize().as_xarray() + if ctx is None: + ctx = delayed_base.OptimizeContext() + memo = ctx.memo + node_id = id(self) + if node_id in memo: + return memo[node_id] + new_subdata = self.subdata.optimize(ctx) + if new_subdata is self.subdata: + new = self + else: + new = new_subdata.as_xarray() if TRACE_OPTIMIZE: new._opt_logs.append('optimize DelayedAsXarray') + memo[node_id] = new return new @@ -1603,7 +1624,7 @@ def _finalize(self): final = kwarray.atleast_nd(final, 3, front=False) return final - def optimize(self): + def optimize(self, ctx=None): """ Returns: DelayedImage @@ -1646,40 +1667,69 @@ def optimize(self): >>> assert len(self.as_graph().nodes) == 2 >>> assert len(new.as_graph().nodes) == 1 """ - new = copy.copy(self) - new.subdata = self.subdata.optimize() - if isinstance2(new.subdata, DelayedWarp): - new = new._opt_fuse_warps() - - # Check if the transform is close enough to identity to be considered - # negligable. - noop_eps = new.meta['noop_eps'] - is_negligable = ( - new.dsize == new.subdata.dsize and - new.transform.isclose_identity(rtol=noop_eps, atol=noop_eps) - ) - if is_negligable: - new = new.subdata - if TRACE_OPTIMIZE: - new._opt_logs.append('Contract identity warp') - elif isinstance2(new.subdata, DelayedChannelConcat): - new = new._opt_push_under_concat().optimize() - elif hasattr(new.subdata, '_optimized_warp'): - # The subdata knows how to optimize itself wrt a warp - warp_kwargs = ub.dict_isect( - self.meta, self._data_keys + self._algo_keys) - new = new.subdata._optimized_warp(**warp_kwargs).optimize() - else: - split = new._opt_split_warp_overview() - if new is not split: - new = split - new.subdata = new.subdata.optimize() - new = new.optimize() + if ctx is None: + ctx = delayed_base.OptimizeContext() + memo = ctx.memo + node_id = id(self) + if node_id in memo: + return memo[node_id] + + node = self + while isinstance2(node, DelayedWarp): + subdata = node.subdata.optimize(ctx) + if subdata is not node.subdata: + node = copy.copy(node) + node.subdata = subdata + + rewritten = False + if isinstance2(node.subdata, DelayedWarp): + node = node._opt_fuse_warps() + rewritten = True else: - new = new._opt_absorb_overview() + # Check if the transform is close enough to identity to be considered + # negligable. + noop_eps = node.meta['noop_eps'] + is_negligable = ( + node.dsize == node.subdata.dsize and + node.transform.isclose_identity(rtol=noop_eps, atol=noop_eps) + ) + if is_negligable: + node = node.subdata + if TRACE_OPTIMIZE: + node._opt_logs.append('Contract identity warp') + rewritten = True + elif isinstance2(node.subdata, DelayedChannelConcat): + node = node._opt_push_under_concat() + rewritten = True + elif hasattr(node.subdata, '_optimized_warp'): + # The subdata knows how to optimize itself wrt a warp + warp_kwargs = ub.dict_isect( + node.meta, node._data_keys + node._algo_keys) + node = node.subdata._optimized_warp(**warp_kwargs) + rewritten = True + else: + split = node._opt_split_warp_overview() + if node is not split: + node = split + rewritten = True + else: + absorbed = node._opt_absorb_overview() + if absorbed is not node: + node = absorbed + rewritten = True + + if rewritten: + continue + break + + if not isinstance2(node, DelayedWarp): + result = node.optimize(ctx) + else: + result = node if TRACE_OPTIMIZE: - new._opt_logs.append('optimize DelayedWarp') - return new + result._opt_logs.append('optimize DelayedWarp') + memo[node_id] = result + return result def _transform_from_subdata(self): return self.transform @@ -2091,7 +2141,7 @@ def _finalize(self): final = dequantize(final, quantization) return final - def optimize(self): + def optimize(self, ctx=None): """ Returns: @@ -2108,22 +2158,44 @@ def optimize(self): >>> self.write_network_text() >>> opt = self.optimize() """ - new = copy.copy(self) - new.subdata = self.subdata.optimize() - - if isinstance2(new.subdata, DelayedDequantize): - raise AssertionError('Dequantization is only allowed once') - - if isinstance2(new.subdata, DelayedWarp): - # Swap order so quantize is before the warp - new = new._opt_dequant_before_other() - new = new.optimize() - - if isinstance2(new.subdata, DelayedChannelConcat): - new = new._opt_push_under_concat().optimize() + if ctx is None: + ctx = delayed_base.OptimizeContext() + memo = ctx.memo + node_id = id(self) + if node_id in memo: + return memo[node_id] + + node = self + while isinstance2(node, DelayedDequantize): + subdata = node.subdata.optimize(ctx) + if subdata is not node.subdata: + node = copy.copy(node) + node.subdata = subdata + + rewritten = False + if isinstance2(node.subdata, DelayedDequantize): + raise AssertionError('Dequantization is only allowed once') + + if isinstance2(node.subdata, DelayedWarp): + # Swap order so quantize is before the warp + node = node._opt_dequant_before_other() + rewritten = True + elif isinstance2(node.subdata, DelayedChannelConcat): + node = node._opt_push_under_concat() + rewritten = True + + if rewritten: + continue + break + + if not isinstance2(node, DelayedDequantize): + result = node.optimize(ctx) + else: + result = node if TRACE_OPTIMIZE: - new._opt_logs.append('optimize DelayedDequantize') - return new + result._opt_logs.append('optimize DelayedDequantize') + memo[node_id] = result + return result def _opt_dequant_before_other(self): quantization = self.meta['quantization'] @@ -2236,7 +2308,7 @@ def _transform_from_subdata(self): self_from_subdata = kwimage.Affine.translate(offset) return self_from_subdata - def optimize(self): + def optimize(self, ctx=None): """ Returns: DelayedImage @@ -2253,48 +2325,75 @@ def optimize(self): >>> new.write_network_text() >>> assert len(new.as_graph().nodes) == 1 """ - new = copy.copy(self) - new.subdata = self.subdata.optimize() - if isinstance2(new.subdata, DelayedCrop): - new = new._opt_fuse_crops() - - if hasattr(new.subdata, '_optimized_crop'): - # The subdata knows how to optimize itself wrt this node - crop_kwargs = ub.dict_isect(self.meta, {'space_slice', 'chan_idxs'}) - new = new.subdata._optimized_crop(**crop_kwargs).optimize() - if isinstance2(new.subdata, DelayedWarp): - new = new._opt_warp_after_crop() - new = new.optimize() - elif isinstance2(new.subdata, DelayedDequantize): - new = new._opt_dequant_after_crop() - new = new.optimize() - - if isinstance2(new.subdata, DelayedChannelConcat): - if isinstance2(new, DelayedCrop): - # We have to be careful if there we have band selection - chan_idxs = new.meta.get('chan_idxs', None) - space_slice = new.meta.get('space_slice', None) - taken = new.subdata - if TRACE_OPTIMIZE: - _new_logs = [] - if chan_idxs is not None: + if ctx is None: + ctx = delayed_base.OptimizeContext() + memo = ctx.memo + node_id = id(self) + if node_id in memo: + return memo[node_id] + + node = self + while isinstance2(node, DelayedCrop): + subdata = node.subdata.optimize(ctx) + if subdata is not node.subdata: + node = copy.copy(node) + node.subdata = subdata + + rewritten = False + if isinstance2(node.subdata, DelayedCrop): + node = node._opt_fuse_crops() + rewritten = True + + if not rewritten and hasattr(node.subdata, '_optimized_crop'): + # The subdata knows how to optimize itself wrt this node + crop_kwargs = ub.dict_isect(node.meta, {'space_slice', 'chan_idxs'}) + node = node.subdata._optimized_crop(**crop_kwargs) + rewritten = True + + if not rewritten and isinstance2(node.subdata, DelayedWarp): + node = node._opt_warp_after_crop() + rewritten = True + elif not rewritten and isinstance2(node.subdata, DelayedDequantize): + node = node._opt_dequant_after_crop() + rewritten = True + + if not rewritten and isinstance2(node.subdata, DelayedChannelConcat): + if isinstance2(node, DelayedCrop): + # We have to be careful if there we have band selection + chan_idxs = node.meta.get('chan_idxs', None) + space_slice = node.meta.get('space_slice', None) + taken = node.subdata if TRACE_OPTIMIZE: - _new_logs.extend(new.subdata._opt_logs) - _new_logs.extend(new._opt_logs) - _new_logs.append('concat-chan-crop-interact') - taken = new.subdata.take_channels(chan_idxs).optimize() - if space_slice is not None: + _new_logs = [] + if chan_idxs is not None: + if TRACE_OPTIMIZE: + _new_logs.extend(node.subdata._opt_logs) + _new_logs.extend(node._opt_logs) + _new_logs.append('concat-chan-crop-interact') + taken = node.subdata.take_channels(chan_idxs) + if space_slice is not None: + if TRACE_OPTIMIZE: + _new_logs.append('concat-space-crop-interact') + taken = taken.crop(space_slice)._opt_push_under_concat() + node = taken if TRACE_OPTIMIZE: - _new_logs.append('concat-space-crop-interact') - taken = taken.crop(space_slice)._opt_push_under_concat().optimize() - new = taken - if TRACE_OPTIMIZE: - new._opt_logs.extend(_new_logs) - else: - new = new._opt_push_under_concat().optimize() + node._opt_logs.extend(_new_logs) + else: + node = node._opt_push_under_concat() + rewritten = True + + if rewritten: + continue + break + + if not isinstance2(node, DelayedCrop): + result = node.optimize(ctx) + else: + result = node if TRACE_OPTIMIZE: - new._opt_logs.append('optimize crop') - return new + result._opt_logs.append('optimize crop') + memo[node_id] = result + return result def _opt_fuse_crops(self): """ @@ -2561,32 +2660,58 @@ def _finalize(self): ) return final - def optimize(self): + def optimize(self, ctx=None): """ Returns: DelayedImage """ - new = copy.copy(self) - new.subdata = self.subdata.optimize() - if isinstance2(new.subdata, DelayedOverview): - new = new._opt_fuse_overview() - - if new.meta['overview'] == 0: - new = new.subdata - elif isinstance2(new.subdata, DelayedCrop): - new = new._opt_crop_after_overview() - new = new.optimize() - elif isinstance2(new.subdata, DelayedWarp): - new = new._opt_warp_after_overview() - new = new.optimize() - elif isinstance2(new.subdata, DelayedDequantize): - new = new._opt_dequant_after_overview() - new = new.optimize() - if isinstance2(new.subdata, DelayedChannelConcat): - new = new._opt_push_under_concat().optimize() + if ctx is None: + ctx = delayed_base.OptimizeContext() + memo = ctx.memo + node_id = id(self) + if node_id in memo: + return memo[node_id] + + node = self + while isinstance2(node, DelayedOverview): + subdata = node.subdata.optimize(ctx) + if subdata is not node.subdata: + node = copy.copy(node) + node.subdata = subdata + + rewritten = False + if isinstance2(node.subdata, DelayedOverview): + node = node._opt_fuse_overview() + rewritten = True + + if not rewritten and node.meta['overview'] == 0: + node = node.subdata + rewritten = True + elif not rewritten and isinstance2(node.subdata, DelayedCrop): + node = node._opt_crop_after_overview() + rewritten = True + elif not rewritten and isinstance2(node.subdata, DelayedWarp): + node = node._opt_warp_after_overview() + rewritten = True + elif not rewritten and isinstance2(node.subdata, DelayedDequantize): + node = node._opt_dequant_after_overview() + rewritten = True + elif not rewritten and isinstance2(node.subdata, DelayedChannelConcat): + node = node._opt_push_under_concat() + rewritten = True + + if rewritten: + continue + break + + if not isinstance2(node, DelayedOverview): + result = node.optimize(ctx) + else: + result = node if TRACE_OPTIMIZE: - new._opt_logs.append('optimize overview') - return new + result._opt_logs.append('optimize overview') + memo[node_id] = result + return result def _transform_from_subdata(self): scale = 1 / 2 ** self.meta['overview'] diff --git a/delayed_image/delayed_nodes.pyi b/delayed_image/delayed_nodes.pyi index 3c3f2c4..fc77e6a 100644 --- a/delayed_image/delayed_nodes.pyi +++ b/delayed_image/delayed_nodes.pyi @@ -6,7 +6,7 @@ from typing import Dict from typing import Any from _typeshed import Incomplete from delayed_image import channel_spec -from delayed_image.delayed_base import DelayedNaryOperation, DelayedUnaryOperation +from delayed_image.delayed_base import DelayedNaryOperation, DelayedUnaryOperation, OptimizeContext from delayed_image.channel_spec import FusedChannelSpec from delayed_image.delayed_leafs import DelayedIdentity @@ -116,7 +116,7 @@ class DelayedChannelConcat(ImageOpsMixin, DelayedConcat): def shape(self) -> Tuple[int | None, int | None, int | None]: ... - def optimize(self) -> DelayedImage: + def optimize(self, ctx: OptimizeContext | None = None) -> DelayedImage: ... def take_channels( @@ -203,7 +203,7 @@ class DelayedImage(ImageOpsMixin, DelayedArray): class DelayedAsXarray(DelayedImage): - def optimize(self) -> DelayedImage: + def optimize(self, ctx: OptimizeContext | None = None) -> DelayedImage: ... @@ -223,7 +223,7 @@ class DelayedWarp(DelayedImage): def transform(self) -> kwimage.Affine: ... - def optimize(self) -> DelayedImage: + def optimize(self, ctx: OptimizeContext | None = None) -> DelayedImage: ... @@ -232,7 +232,7 @@ class DelayedDequantize(DelayedImage): def __init__(self, subdata: DelayedArray, quantization: Dict) -> None: ... - def optimize(self) -> DelayedImage: + def optimize(self, ctx: OptimizeContext | None = None) -> DelayedImage: ... @@ -245,7 +245,7 @@ class DelayedCrop(DelayedImage): chan_idxs: List[int] | None = None) -> None: ... - def optimize(self) -> DelayedImage: + def optimize(self, ctx: OptimizeContext | None = None) -> DelayedImage: ... @@ -258,7 +258,7 @@ class DelayedOverview(DelayedImage): def num_overviews(self) -> int: ... - def optimize(self) -> DelayedImage: + def optimize(self, ctx: OptimizeContext | None = None) -> DelayedImage: ... diff --git a/tests/test_optimize_context.py b/tests/test_optimize_context.py new file mode 100644 index 0000000..b6a778b --- /dev/null +++ b/tests/test_optimize_context.py @@ -0,0 +1,127 @@ +import warnings + +import numpy as np +import pytest + +import delayed_image + + +def _finalize_ignoring_warnings(node): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + return node.finalize() + + +def _require_warp_backend(): + from kwimage import im_transform + backend = im_transform._default_backend() + if backend == 'skimage': + pytest.skip('kwimage warp/imresize backend is unavailable') + + +def test_optimize_idempotence(): + _require_warp_backend() + rng = np.random.default_rng(0) + data = (rng.random((32, 32, 3)) * 255).astype(np.uint8) + base = delayed_image.DelayedIdentity(data, channels='r|g|b') + quantization = {'quant_max': 255, 'nodata': 0} + + node = base.dequantize(quantization) + node = node.warp({'scale': 1.1, 'offset': (2, -1)}, + interpolation='nearest', antialias=False) + node = node.crop((slice(2, 24), slice(3, 25))) + node = node.get_overview(1) + + opt1 = node.optimize() + opt2 = opt1.optimize() + + assert opt1.nesting() == opt2.nesting() + final1 = _finalize_ignoring_warnings(opt1) + final2 = _finalize_ignoring_warnings(opt2) + assert np.allclose(final1, final2, equal_nan=True) + + +def test_repeated_optimize_equivalence(): + _require_warp_backend() + rng = np.random.default_rng(1) + data = (rng.random((48, 48, 3)) * 255).astype(np.uint8) + base = delayed_image.DelayedIdentity(data, channels='r|g|b') + quantization = {'quant_max': 255, 'nodata': 0} + + node = base.warp({'scale': (1.2, 0.9), 'theta': 0.05}, + interpolation='linear') + node = node.crop((slice(4, 40), slice(5, 41))) + node = node.dequantize(quantization) + + opt1 = node.optimize() + opt2 = node.optimize() + + final_orig = _finalize_ignoring_warnings(node) + final1 = _finalize_ignoring_warnings(opt1) + final2 = _finalize_ignoring_warnings(opt2) + + assert np.allclose(final1, final2, equal_nan=True) + assert np.allclose(final_orig, final1, equal_nan=True) + + +def test_randomized_tree_finalize_equivalence(): + _require_warp_backend() + rng = np.random.default_rng(2) + data = (rng.random((64, 64, 3)) * 255).astype(np.uint8) + base = delayed_image.DelayedIdentity(data, channels='r|g|b') + quantization = {'quant_max': 255, 'nodata': 0} + + node = base.dequantize(quantization) + node = node.get_overview(1) + node = node.scale(rng.uniform(0.6, 1.4), dsize='auto', + interpolation='linear', antialias=True) + node = node.warp({'scale': (rng.uniform(0.7, 1.3), rng.uniform(0.7, 1.3)), + 'offset': (rng.uniform(-5, 5), rng.uniform(-5, 5)), + 'theta': rng.uniform(-0.2, 0.2)}, + dsize='auto', interpolation='nearest') + + w, h = node.dsize + y0 = rng.integers(0, max(1, h // 4)) + y1 = rng.integers(max(y0 + 1, h // 2), h) + x0 = rng.integers(0, max(1, w // 4)) + x1 = rng.integers(max(x0 + 1, w // 2), w) + node = node.crop((slice(int(y0), int(y1)), slice(int(x0), int(x1)))) + + final_raw = _finalize_ignoring_warnings(node) + final_opt = _finalize_ignoring_warnings(node.optimize()) + assert np.allclose(final_raw, final_opt, equal_nan=True) + + +def test_optimize_preserves_metadata(tmp_path): + _require_warp_backend() + rng = np.random.default_rng(3) + data = (rng.random((64, 64, 3)) * 255).astype(np.uint8) + fpath = tmp_path / 'meta.png' + import kwimage + kwimage.imwrite(str(fpath), data) + base = delayed_image.DelayedLoad( + fpath, channels='r|g|b', nodata_method='float').prepare() + quantization = {'quant_max': 255, 'nodata': 0} + + node = base.dequantize(quantization) + node = node.warp({'scale': 1.3, 'offset': (2, -1)}, + interpolation='nearest', antialias=False, + border_value=0, dsize='auto') + node = node.crop((slice(5, 40), slice(4, 50))) + + opt = node.optimize() + + assert opt.channels == node.channels + assert opt.dsize == node.dsize + + warp_nodes = [n for _, n in opt._traverse() + if isinstance(n, delayed_image.DelayedWarp)] + assert warp_nodes, 'optimized graph should retain a warp' + warp = warp_nodes[0] + assert warp.meta['interpolation'] == 'nearest' + assert warp.meta['antialias'] is False + + load_nodes = [n for _, n in opt._traverse() + if isinstance(n, delayed_image.DelayedLoad)] + assert load_nodes, 'optimized graph should retain a load node' + assert load_nodes[0].meta['nodata_method'] == 'float' From fb46891346357dc305e05a14758bd765d68c00bb Mon Sep 17 00:00:00 2001 From: Jon Crall Date: Fri, 23 Jan 2026 19:07:16 -0500 Subject: [PATCH 2/7] Refine optimize fixed-point loops and py38 annotations --- delayed_image/delayed_base.py | 1 + delayed_image/delayed_leafs.py | 1 + delayed_image/delayed_nodes.py | 179 ++++++++++++++++----------------- tests/test_optimize_context.py | 4 + 4 files changed, 94 insertions(+), 91 deletions(-) diff --git a/delayed_image/delayed_base.py b/delayed_image/delayed_base.py index 54c80a1..2961566 100644 --- a/delayed_image/delayed_base.py +++ b/delayed_image/delayed_base.py @@ -1,6 +1,7 @@ """ Abstract nodes """ +from __future__ import annotations import numpy as np import ubelt as ub diff --git a/delayed_image/delayed_leafs.py b/delayed_image/delayed_leafs.py index c6cb5dd..7a99fed 100644 --- a/delayed_image/delayed_leafs.py +++ b/delayed_image/delayed_leafs.py @@ -1,6 +1,7 @@ """ Terminal nodes """ +from __future__ import annotations import kwarray import kwimage diff --git a/delayed_image/delayed_nodes.py b/delayed_image/delayed_nodes.py index 8b7bb54..e8f436c 100644 --- a/delayed_image/delayed_nodes.py +++ b/delayed_image/delayed_nodes.py @@ -1,6 +1,7 @@ """ Intermediate operations """ +from __future__ import annotations import kwarray import kwimage import copy @@ -1675,57 +1676,61 @@ def optimize(self, ctx=None): return memo[node_id] node = self - while isinstance2(node, DelayedWarp): + while True: subdata = node.subdata.optimize(ctx) if subdata is not node.subdata: node = copy.copy(node) node.subdata = subdata - rewritten = False if isinstance2(node.subdata, DelayedWarp): node = node._opt_fuse_warps() - rewritten = True - else: - # Check if the transform is close enough to identity to be considered - # negligable. - noop_eps = node.meta['noop_eps'] - is_negligable = ( - node.dsize == node.subdata.dsize and - node.transform.isclose_identity(rtol=noop_eps, atol=noop_eps) - ) - if is_negligable: - node = node.subdata - if TRACE_OPTIMIZE: - node._opt_logs.append('Contract identity warp') - rewritten = True - elif isinstance2(node.subdata, DelayedChannelConcat): - node = node._opt_push_under_concat() - rewritten = True - elif hasattr(node.subdata, '_optimized_warp'): - # The subdata knows how to optimize itself wrt a warp - warp_kwargs = ub.dict_isect( - node.meta, node._data_keys + node._algo_keys) - node = node.subdata._optimized_warp(**warp_kwargs) - rewritten = True - else: - split = node._opt_split_warp_overview() - if node is not split: - node = split - rewritten = True - else: - absorbed = node._opt_absorb_overview() - if absorbed is not node: - node = absorbed - rewritten = True - - if rewritten: continue - break - if not isinstance2(node, DelayedWarp): - result = node.optimize(ctx) - else: + # Check if the transform is close enough to identity to be considered + # negligable. + noop_eps = node.meta['noop_eps'] + is_negligable = ( + node.dsize == node.subdata.dsize and + node.transform.isclose_identity(rtol=noop_eps, atol=noop_eps) + ) + if is_negligable: + node = node.subdata + if TRACE_OPTIMIZE: + node._opt_logs.append('Contract identity warp') + result = node + break + + if isinstance2(node.subdata, DelayedChannelConcat): + node = node._opt_push_under_concat() + result = node.optimize(ctx) + break + + if hasattr(node.subdata, '_optimized_warp'): + # The subdata knows how to optimize itself wrt a warp + warp_kwargs = ub.dict_isect( + node.meta, node._data_keys + node._algo_keys) + node = node.subdata._optimized_warp(**warp_kwargs) + result = node.optimize(ctx) + break + + split = node._opt_split_warp_overview() + if node is not split: + node = split + if not isinstance2(node, DelayedWarp): + result = node.optimize(ctx) + break + continue + + absorbed = node._opt_absorb_overview() + if absorbed is not node: + node = absorbed + if not isinstance2(node, DelayedWarp): + result = node.optimize(ctx) + break + continue + result = node + break if TRACE_OPTIMIZE: result._opt_logs.append('optimize DelayedWarp') memo[node_id] = result @@ -2166,32 +2171,28 @@ def optimize(self, ctx=None): return memo[node_id] node = self - while isinstance2(node, DelayedDequantize): + while True: subdata = node.subdata.optimize(ctx) if subdata is not node.subdata: node = copy.copy(node) node.subdata = subdata - rewritten = False if isinstance2(node.subdata, DelayedDequantize): raise AssertionError('Dequantization is only allowed once') if isinstance2(node.subdata, DelayedWarp): # Swap order so quantize is before the warp node = node._opt_dequant_before_other() - rewritten = True - elif isinstance2(node.subdata, DelayedChannelConcat): - node = node._opt_push_under_concat() - rewritten = True + result = node.optimize(ctx) + break - if rewritten: - continue - break + if isinstance2(node.subdata, DelayedChannelConcat): + node = node._opt_push_under_concat() + result = node.optimize(ctx) + break - if not isinstance2(node, DelayedDequantize): - result = node.optimize(ctx) - else: result = node + break if TRACE_OPTIMIZE: result._opt_logs.append('optimize DelayedDequantize') memo[node_id] = result @@ -2333,31 +2334,33 @@ def optimize(self, ctx=None): return memo[node_id] node = self - while isinstance2(node, DelayedCrop): + while True: subdata = node.subdata.optimize(ctx) if subdata is not node.subdata: node = copy.copy(node) node.subdata = subdata - rewritten = False if isinstance2(node.subdata, DelayedCrop): node = node._opt_fuse_crops() - rewritten = True + continue - if not rewritten and hasattr(node.subdata, '_optimized_crop'): + if hasattr(node.subdata, '_optimized_crop'): # The subdata knows how to optimize itself wrt this node crop_kwargs = ub.dict_isect(node.meta, {'space_slice', 'chan_idxs'}) node = node.subdata._optimized_crop(**crop_kwargs) - rewritten = True + result = node.optimize(ctx) + break - if not rewritten and isinstance2(node.subdata, DelayedWarp): + if isinstance2(node.subdata, DelayedWarp): node = node._opt_warp_after_crop() - rewritten = True - elif not rewritten and isinstance2(node.subdata, DelayedDequantize): + result = node.optimize(ctx) + break + if isinstance2(node.subdata, DelayedDequantize): node = node._opt_dequant_after_crop() - rewritten = True + result = node.optimize(ctx) + break - if not rewritten and isinstance2(node.subdata, DelayedChannelConcat): + if isinstance2(node.subdata, DelayedChannelConcat): if isinstance2(node, DelayedCrop): # We have to be careful if there we have band selection chan_idxs = node.meta.get('chan_idxs', None) @@ -2380,16 +2383,11 @@ def optimize(self, ctx=None): node._opt_logs.extend(_new_logs) else: node = node._opt_push_under_concat() - rewritten = True - - if rewritten: - continue - break + result = node.optimize(ctx) + break - if not isinstance2(node, DelayedCrop): - result = node.optimize(ctx) - else: result = node + break if TRACE_OPTIMIZE: result._opt_logs.append('optimize crop') memo[node_id] = result @@ -2673,41 +2671,40 @@ def optimize(self, ctx=None): return memo[node_id] node = self - while isinstance2(node, DelayedOverview): + while True: subdata = node.subdata.optimize(ctx) if subdata is not node.subdata: node = copy.copy(node) node.subdata = subdata - rewritten = False if isinstance2(node.subdata, DelayedOverview): node = node._opt_fuse_overview() - rewritten = True + continue - if not rewritten and node.meta['overview'] == 0: + if node.meta['overview'] == 0: node = node.subdata - rewritten = True - elif not rewritten and isinstance2(node.subdata, DelayedCrop): + result = node + break + + if isinstance2(node.subdata, DelayedCrop): node = node._opt_crop_after_overview() - rewritten = True - elif not rewritten and isinstance2(node.subdata, DelayedWarp): + result = node.optimize(ctx) + break + if isinstance2(node.subdata, DelayedWarp): node = node._opt_warp_after_overview() - rewritten = True - elif not rewritten and isinstance2(node.subdata, DelayedDequantize): + result = node.optimize(ctx) + break + if isinstance2(node.subdata, DelayedDequantize): node = node._opt_dequant_after_overview() - rewritten = True - elif not rewritten and isinstance2(node.subdata, DelayedChannelConcat): + result = node.optimize(ctx) + break + if isinstance2(node.subdata, DelayedChannelConcat): node = node._opt_push_under_concat() - rewritten = True - - if rewritten: - continue - break + result = node.optimize(ctx) + break - if not isinstance2(node, DelayedOverview): - result = node.optimize(ctx) - else: result = node + break if TRACE_OPTIMIZE: result._opt_logs.append('optimize overview') memo[node_id] = result diff --git a/tests/test_optimize_context.py b/tests/test_optimize_context.py index b6a778b..a0e5fd9 100644 --- a/tests/test_optimize_context.py +++ b/tests/test_optimize_context.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import warnings import numpy as np @@ -24,6 +26,7 @@ def test_optimize_idempotence(): rng = np.random.default_rng(0) data = (rng.random((32, 32, 3)) * 255).astype(np.uint8) base = delayed_image.DelayedIdentity(data, channels='r|g|b') + base.meta['num_overviews'] = 1 quantization = {'quant_max': 255, 'nodata': 0} node = base.dequantize(quantization) @@ -69,6 +72,7 @@ def test_randomized_tree_finalize_equivalence(): rng = np.random.default_rng(2) data = (rng.random((64, 64, 3)) * 255).astype(np.uint8) base = delayed_image.DelayedIdentity(data, channels='r|g|b') + base.meta['num_overviews'] = 1 quantization = {'quant_max': 255, 'nodata': 0} node = base.dequantize(quantization) From 3f14bd28f93d0aae087215f17053ecd379240e20 Mon Sep 17 00:00:00 2001 From: Jon Crall Date: Sat, 31 Jan 2026 22:34:50 -0500 Subject: [PATCH 3/7] Align warp optimize split flow with legacy behavior --- delayed_image/delayed_nodes.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/delayed_image/delayed_nodes.py b/delayed_image/delayed_nodes.py index e8f436c..9041ca7 100644 --- a/delayed_image/delayed_nodes.py +++ b/delayed_image/delayed_nodes.py @@ -1716,21 +1716,13 @@ def optimize(self, ctx=None): split = node._opt_split_warp_overview() if node is not split: node = split - if not isinstance2(node, DelayedWarp): - result = node.optimize(ctx) - break - continue - - absorbed = node._opt_absorb_overview() - if absorbed is not node: - node = absorbed - if not isinstance2(node, DelayedWarp): - result = node.optimize(ctx) - break - continue - - result = node - break + node.subdata = node.subdata.optimize(ctx) + result = node.optimize(ctx) + break + else: + node = node._opt_absorb_overview() + result = node + break if TRACE_OPTIMIZE: result._opt_logs.append('optimize DelayedWarp') memo[node_id] = result From be193009db0e451dede5c802cc570146358c63b5 Mon Sep 17 00:00:00 2001 From: Jon Crall Date: Sun, 1 Feb 2026 12:35:56 -0500 Subject: [PATCH 4/7] Restore optimize rewrite semantics with memoization --- delayed_image/delayed_nodes.py | 274 +++++++++++++-------------------- 1 file changed, 107 insertions(+), 167 deletions(-) diff --git a/delayed_image/delayed_nodes.py b/delayed_image/delayed_nodes.py index 9041ca7..e662907 100644 --- a/delayed_image/delayed_nodes.py +++ b/delayed_image/delayed_nodes.py @@ -1675,58 +1675,41 @@ def optimize(self, ctx=None): if node_id in memo: return memo[node_id] - node = self - while True: - subdata = node.subdata.optimize(ctx) - if subdata is not node.subdata: - node = copy.copy(node) - node.subdata = subdata - - if isinstance2(node.subdata, DelayedWarp): - node = node._opt_fuse_warps() - continue - - # Check if the transform is close enough to identity to be considered - # negligable. - noop_eps = node.meta['noop_eps'] - is_negligable = ( - node.dsize == node.subdata.dsize and - node.transform.isclose_identity(rtol=noop_eps, atol=noop_eps) - ) - if is_negligable: - node = node.subdata - if TRACE_OPTIMIZE: - node._opt_logs.append('Contract identity warp') - result = node - break - - if isinstance2(node.subdata, DelayedChannelConcat): - node = node._opt_push_under_concat() - result = node.optimize(ctx) - break - - if hasattr(node.subdata, '_optimized_warp'): - # The subdata knows how to optimize itself wrt a warp - warp_kwargs = ub.dict_isect( - node.meta, node._data_keys + node._algo_keys) - node = node.subdata._optimized_warp(**warp_kwargs) - result = node.optimize(ctx) - break - - split = node._opt_split_warp_overview() - if node is not split: - node = split - node.subdata = node.subdata.optimize(ctx) - result = node.optimize(ctx) - break + new = copy.copy(self) + new.subdata = self.subdata.optimize(ctx) + if isinstance2(new.subdata, DelayedWarp): + new = new._opt_fuse_warps() + + # Check if the transform is close enough to identity to be considered + # negligable. + noop_eps = new.meta['noop_eps'] + is_negligable = ( + new.dsize == new.subdata.dsize and + new.transform.isclose_identity(rtol=noop_eps, atol=noop_eps) + ) + if is_negligable: + new = new.subdata + if TRACE_OPTIMIZE: + new._opt_logs.append('Contract identity warp') + elif isinstance2(new.subdata, DelayedChannelConcat): + new = new._opt_push_under_concat().optimize(ctx) + elif hasattr(new.subdata, '_optimized_warp'): + # The subdata knows how to optimize itself wrt a warp + warp_kwargs = ub.dict_isect( + self.meta, self._data_keys + self._algo_keys) + new = new.subdata._optimized_warp(**warp_kwargs).optimize(ctx) + else: + split = new._opt_split_warp_overview() + if new is not split: + new = split + new.subdata = new.subdata.optimize(ctx) + new = new.optimize(ctx) else: - node = node._opt_absorb_overview() - result = node - break + new = new._opt_absorb_overview() if TRACE_OPTIMIZE: - result._opt_logs.append('optimize DelayedWarp') - memo[node_id] = result - return result + new._opt_logs.append('optimize DelayedWarp') + memo[node_id] = new + return new def _transform_from_subdata(self): return self.transform @@ -2162,33 +2145,23 @@ def optimize(self, ctx=None): if node_id in memo: return memo[node_id] - node = self - while True: - subdata = node.subdata.optimize(ctx) - if subdata is not node.subdata: - node = copy.copy(node) - node.subdata = subdata - - if isinstance2(node.subdata, DelayedDequantize): - raise AssertionError('Dequantization is only allowed once') + new = copy.copy(self) + new.subdata = self.subdata.optimize(ctx) - if isinstance2(node.subdata, DelayedWarp): - # Swap order so quantize is before the warp - node = node._opt_dequant_before_other() - result = node.optimize(ctx) - break + if isinstance2(new.subdata, DelayedDequantize): + raise AssertionError('Dequantization is only allowed once') - if isinstance2(node.subdata, DelayedChannelConcat): - node = node._opt_push_under_concat() - result = node.optimize(ctx) - break + if isinstance2(new.subdata, DelayedWarp): + # Swap order so quantize is before the warp + new = new._opt_dequant_before_other() + new = new.optimize(ctx) - result = node - break + if isinstance2(new.subdata, DelayedChannelConcat): + new = new._opt_push_under_concat().optimize(ctx) if TRACE_OPTIMIZE: - result._opt_logs.append('optimize DelayedDequantize') - memo[node_id] = result - return result + new._opt_logs.append('optimize DelayedDequantize') + memo[node_id] = new + return new def _opt_dequant_before_other(self): quantization = self.meta['quantization'] @@ -2325,65 +2298,49 @@ def optimize(self, ctx=None): if node_id in memo: return memo[node_id] - node = self - while True: - subdata = node.subdata.optimize(ctx) - if subdata is not node.subdata: - node = copy.copy(node) - node.subdata = subdata - - if isinstance2(node.subdata, DelayedCrop): - node = node._opt_fuse_crops() - continue - - if hasattr(node.subdata, '_optimized_crop'): - # The subdata knows how to optimize itself wrt this node - crop_kwargs = ub.dict_isect(node.meta, {'space_slice', 'chan_idxs'}) - node = node.subdata._optimized_crop(**crop_kwargs) - result = node.optimize(ctx) - break - - if isinstance2(node.subdata, DelayedWarp): - node = node._opt_warp_after_crop() - result = node.optimize(ctx) - break - if isinstance2(node.subdata, DelayedDequantize): - node = node._opt_dequant_after_crop() - result = node.optimize(ctx) - break - - if isinstance2(node.subdata, DelayedChannelConcat): - if isinstance2(node, DelayedCrop): - # We have to be careful if there we have band selection - chan_idxs = node.meta.get('chan_idxs', None) - space_slice = node.meta.get('space_slice', None) - taken = node.subdata + new = copy.copy(self) + new.subdata = self.subdata.optimize(ctx) + if isinstance2(new.subdata, DelayedCrop): + new = new._opt_fuse_crops() + + if hasattr(new.subdata, '_optimized_crop'): + # The subdata knows how to optimize itself wrt this node + crop_kwargs = ub.dict_isect(self.meta, {'space_slice', 'chan_idxs'}) + new = new.subdata._optimized_crop(**crop_kwargs).optimize(ctx) + if isinstance2(new.subdata, DelayedWarp): + new = new._opt_warp_after_crop() + new = new.optimize(ctx) + elif isinstance2(new.subdata, DelayedDequantize): + new = new._opt_dequant_after_crop() + new = new.optimize(ctx) + + if isinstance2(new.subdata, DelayedChannelConcat): + if isinstance2(new, DelayedCrop): + # We have to be careful if there we have band selection + chan_idxs = new.meta.get('chan_idxs', None) + space_slice = new.meta.get('space_slice', None) + taken = new.subdata + if TRACE_OPTIMIZE: + _new_logs = [] + if chan_idxs is not None: if TRACE_OPTIMIZE: - _new_logs = [] - if chan_idxs is not None: - if TRACE_OPTIMIZE: - _new_logs.extend(node.subdata._opt_logs) - _new_logs.extend(node._opt_logs) - _new_logs.append('concat-chan-crop-interact') - taken = node.subdata.take_channels(chan_idxs) - if space_slice is not None: - if TRACE_OPTIMIZE: - _new_logs.append('concat-space-crop-interact') - taken = taken.crop(space_slice)._opt_push_under_concat() - node = taken + _new_logs.extend(new.subdata._opt_logs) + _new_logs.extend(new._opt_logs) + _new_logs.append('concat-chan-crop-interact') + taken = new.subdata.take_channels(chan_idxs).optimize(ctx) + if space_slice is not None: if TRACE_OPTIMIZE: - node._opt_logs.extend(_new_logs) - else: - node = node._opt_push_under_concat() - result = node.optimize(ctx) - break - - result = node - break + _new_logs.append('concat-space-crop-interact') + taken = taken.crop(space_slice)._opt_push_under_concat().optimize(ctx) + new = taken + if TRACE_OPTIMIZE: + new._opt_logs.extend(_new_logs) + else: + new = new._opt_push_under_concat().optimize(ctx) if TRACE_OPTIMIZE: - result._opt_logs.append('optimize crop') - memo[node_id] = result - return result + new._opt_logs.append('optimize crop') + memo[node_id] = new + return new def _opt_fuse_crops(self): """ @@ -2662,45 +2619,28 @@ def optimize(self, ctx=None): if node_id in memo: return memo[node_id] - node = self - while True: - subdata = node.subdata.optimize(ctx) - if subdata is not node.subdata: - node = copy.copy(node) - node.subdata = subdata - - if isinstance2(node.subdata, DelayedOverview): - node = node._opt_fuse_overview() - continue - - if node.meta['overview'] == 0: - node = node.subdata - result = node - break - - if isinstance2(node.subdata, DelayedCrop): - node = node._opt_crop_after_overview() - result = node.optimize(ctx) - break - if isinstance2(node.subdata, DelayedWarp): - node = node._opt_warp_after_overview() - result = node.optimize(ctx) - break - if isinstance2(node.subdata, DelayedDequantize): - node = node._opt_dequant_after_overview() - result = node.optimize(ctx) - break - if isinstance2(node.subdata, DelayedChannelConcat): - node = node._opt_push_under_concat() - result = node.optimize(ctx) - break - - result = node - break + new = copy.copy(self) + new.subdata = self.subdata.optimize(ctx) + if isinstance2(new.subdata, DelayedOverview): + new = new._opt_fuse_overview() + + if new.meta['overview'] == 0: + new = new.subdata + elif isinstance2(new.subdata, DelayedCrop): + new = new._opt_crop_after_overview() + new = new.optimize(ctx) + elif isinstance2(new.subdata, DelayedWarp): + new = new._opt_warp_after_overview() + new = new.optimize(ctx) + elif isinstance2(new.subdata, DelayedDequantize): + new = new._opt_dequant_after_overview() + new = new.optimize(ctx) + if isinstance2(new.subdata, DelayedChannelConcat): + new = new._opt_push_under_concat().optimize(ctx) if TRACE_OPTIMIZE: - result._opt_logs.append('optimize overview') - memo[node_id] = result - return result + new._opt_logs.append('optimize overview') + memo[node_id] = new + return new def _transform_from_subdata(self): scale = 1 / 2 ** self.meta['overview'] From 4579e9e6a1f6135467243b0e77174cc8f2db217f Mon Sep 17 00:00:00 2001 From: Jon Crall Date: Sun, 1 Feb 2026 18:44:57 -0500 Subject: [PATCH 5/7] Guard concat rewrites and auto dsize --- delayed_image/delayed_nodes.py | 45 +++++++++++++++++++++++++++------- delayed_image/helpers.py | 14 ++++++----- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/delayed_image/delayed_nodes.py b/delayed_image/delayed_nodes.py index e662907..dac7dfe 100644 --- a/delayed_image/delayed_nodes.py +++ b/delayed_image/delayed_nodes.py @@ -1315,9 +1315,13 @@ def _opt_push_under_concat(self): """ Push this node under its child node if it is a concatenation operation """ - assert isinstance2(self.subdata, DelayedChannelConcat) + if not isinstance2(self.subdata, DelayedChannelConcat): + return self kwargs = ub.compatible(self.meta, self.__class__.__init__) - new = self.subdata._push_operation_under(self.__class__, kwargs) + try: + new = self.subdata._push_operation_under(self.__class__, kwargs) + except CoordinateCompatibilityError: + return self if TRACE_OPTIMIZE: new._opt_logs.append('_opt_push_under_concat') return new @@ -1692,7 +1696,11 @@ def optimize(self, ctx=None): if TRACE_OPTIMIZE: new._opt_logs.append('Contract identity warp') elif isinstance2(new.subdata, DelayedChannelConcat): - new = new._opt_push_under_concat().optimize(ctx) + pushed = new._opt_push_under_concat() + if pushed is not new: + new = pushed.optimize(ctx) + else: + new = pushed elif hasattr(new.subdata, '_optimized_warp'): # The subdata knows how to optimize itself wrt a warp warp_kwargs = ub.dict_isect( @@ -2157,7 +2165,11 @@ def optimize(self, ctx=None): new = new.optimize(ctx) if isinstance2(new.subdata, DelayedChannelConcat): - new = new._opt_push_under_concat().optimize(ctx) + pushed = new._opt_push_under_concat() + if pushed is not new: + new = pushed.optimize(ctx) + else: + new = pushed if TRACE_OPTIMIZE: new._opt_logs.append('optimize DelayedDequantize') memo[node_id] = new @@ -2308,8 +2320,9 @@ def optimize(self, ctx=None): crop_kwargs = ub.dict_isect(self.meta, {'space_slice', 'chan_idxs'}) new = new.subdata._optimized_crop(**crop_kwargs).optimize(ctx) if isinstance2(new.subdata, DelayedWarp): - new = new._opt_warp_after_crop() - new = new.optimize(ctx) + if 0 not in new.meta.get('dsize', ()): + new = new._opt_warp_after_crop() + new = new.optimize(ctx) elif isinstance2(new.subdata, DelayedDequantize): new = new._opt_dequant_after_crop() new = new.optimize(ctx) @@ -2331,12 +2344,20 @@ def optimize(self, ctx=None): if space_slice is not None: if TRACE_OPTIMIZE: _new_logs.append('concat-space-crop-interact') - taken = taken.crop(space_slice)._opt_push_under_concat().optimize(ctx) + pushed = taken.crop(space_slice)._opt_push_under_concat() + if pushed is not taken: + taken = pushed.optimize(ctx) + else: + taken = pushed new = taken if TRACE_OPTIMIZE: new._opt_logs.extend(_new_logs) else: - new = new._opt_push_under_concat().optimize(ctx) + pushed = new._opt_push_under_concat() + if pushed is not new: + new = pushed.optimize(ctx) + else: + new = pushed if TRACE_OPTIMIZE: new._opt_logs.append('optimize crop') memo[node_id] = new @@ -2473,6 +2494,8 @@ def _opt_warp_after_crop(self): >>> print(ub.urepr(new_outer.nesting(), nl=-1, sort=0)) """ assert isinstance2(self.subdata, DelayedWarp) + if 0 in self.meta.get('dsize', ()): + return self # Inner is the data closer to the leaf (disk), outer is the data closer # to the user (output). outer_slices = self.meta['space_slice'] @@ -2636,7 +2659,11 @@ def optimize(self, ctx=None): new = new._opt_dequant_after_overview() new = new.optimize(ctx) if isinstance2(new.subdata, DelayedChannelConcat): - new = new._opt_push_under_concat().optimize(ctx) + pushed = new._opt_push_under_concat() + if pushed is not new: + new = pushed.optimize(ctx) + else: + new = pushed if TRACE_OPTIMIZE: new._opt_logs.append('optimize overview') memo[node_id] = new diff --git a/delayed_image/helpers.py b/delayed_image/helpers.py index f38ca96..ede528d 100644 --- a/delayed_image/helpers.py +++ b/delayed_image/helpers.py @@ -22,6 +22,8 @@ def _auto_dsize(transform, sub_dsize): sub_dsize = (512, 512) """ sub_w, sub_h = sub_dsize + if sub_w is None or sub_h is None: + return sub_dsize if 0: sub_bounds = kwimage.Coords( @@ -37,16 +39,16 @@ def _auto_dsize(transform, sub_dsize): # note: this is faster than the above variant but will break on # non-affine (i.e. homogenous) transforms. sub_bounds = np.array([ - [0, 0, 1], - [sub_w, 0, 1], - [0, sub_h, 1], - [sub_w, sub_h, 1] + [0, 0, 1], + [sub_w - 1, 0, 1], + [0, sub_h - 1, 1], + [sub_w - 1, sub_h - 1, 1] ]) # bounds = kwimage.warp_points(transform.matrix, sub_bounds)[0:2] bounds = (transform.matrix[0:2] @ sub_bounds.T).T max_xy = np.ceil(bounds.max(axis=0)) - max_x = int(max_xy[0]) - max_y = int(max_xy[1]) + max_x = int(max_xy[0]) + 1 + max_y = int(max_xy[1]) + 1 dsize = (max_x, max_y) return dsize From 3481342101060639c3f2957a7bfdc22b094d809e Mon Sep 17 00:00:00 2001 From: Jon Crall Date: Sun, 1 Feb 2026 19:37:24 -0500 Subject: [PATCH 6/7] Restore auto dsize and guard concat optimize --- delayed_image/delayed_nodes.py | 5 ++++- delayed_image/helpers.py | 14 ++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/delayed_image/delayed_nodes.py b/delayed_image/delayed_nodes.py index dac7dfe..433d192 100644 --- a/delayed_image/delayed_nodes.py +++ b/delayed_image/delayed_nodes.py @@ -675,7 +675,10 @@ def optimize(self, ctx=None): new = self else: kw = ub.dict_isect(self.meta, ['dsize']) - new = self.__class__(new_parts, **kw) + try: + new = self.__class__(new_parts, **kw) + except CoordinateCompatibilityError: + new = self if TRACE_OPTIMIZE: new._opt_logs.append('optimize DelayedChannelConcat') memo[node_id] = new diff --git a/delayed_image/helpers.py b/delayed_image/helpers.py index ede528d..f38ca96 100644 --- a/delayed_image/helpers.py +++ b/delayed_image/helpers.py @@ -22,8 +22,6 @@ def _auto_dsize(transform, sub_dsize): sub_dsize = (512, 512) """ sub_w, sub_h = sub_dsize - if sub_w is None or sub_h is None: - return sub_dsize if 0: sub_bounds = kwimage.Coords( @@ -39,16 +37,16 @@ def _auto_dsize(transform, sub_dsize): # note: this is faster than the above variant but will break on # non-affine (i.e. homogenous) transforms. sub_bounds = np.array([ - [0, 0, 1], - [sub_w - 1, 0, 1], - [0, sub_h - 1, 1], - [sub_w - 1, sub_h - 1, 1] + [0, 0, 1], + [sub_w, 0, 1], + [0, sub_h, 1], + [sub_w, sub_h, 1] ]) # bounds = kwimage.warp_points(transform.matrix, sub_bounds)[0:2] bounds = (transform.matrix[0:2] @ sub_bounds.T).T max_xy = np.ceil(bounds.max(axis=0)) - max_x = int(max_xy[0]) + 1 - max_y = int(max_xy[1]) + 1 + max_x = int(max_xy[0]) + max_y = int(max_xy[1]) dsize = (max_x, max_y) return dsize From 4e4ecbdf92207bc90b6b9a3ea0972d5ecec1fd2e Mon Sep 17 00:00:00 2001 From: Jon Crall Date: Sun, 1 Feb 2026 21:39:50 -0500 Subject: [PATCH 7/7] Fix optimize memoization keying --- delayed_image/delayed_leafs.py | 7 +++--- delayed_image/delayed_nodes.py | 42 +++++++++++++++------------------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/delayed_image/delayed_leafs.py b/delayed_image/delayed_leafs.py index 7a99fed..05ded9a 100644 --- a/delayed_image/delayed_leafs.py +++ b/delayed_image/delayed_leafs.py @@ -35,12 +35,11 @@ def optimize(self, ctx=None): if ctx is None: ctx = delayed_base.OptimizeContext() memo = ctx.memo - node_id = id(self) - if node_id in memo: - return memo[node_id] + if self in memo: + return memo[self] if TRACE_OPTIMIZE: self._opt_logs.append('optimize DelayedImageLeaf') - memo[node_id] = self + memo[self] = self return self diff --git a/delayed_image/delayed_nodes.py b/delayed_image/delayed_nodes.py index 433d192..c6017ec 100644 --- a/delayed_image/delayed_nodes.py +++ b/delayed_image/delayed_nodes.py @@ -667,9 +667,8 @@ def optimize(self, ctx=None): if ctx is None: ctx = delayed_base.OptimizeContext() memo = ctx.memo - node_id = id(self) - if node_id in memo: - return memo[node_id] + if self in memo: + return memo[self] new_parts = [part.optimize(ctx) for part in self.parts] if all(p is o for p, o in zip(new_parts, self.parts)): new = self @@ -681,7 +680,7 @@ def optimize(self, ctx=None): new = self if TRACE_OPTIMIZE: new._opt_logs.append('optimize DelayedChannelConcat') - memo[node_id] = new + memo[self] = new return new def take_channels(self, channels, missing_channel_policy='return_nan'): @@ -1478,9 +1477,8 @@ def optimize(self, ctx=None): if ctx is None: ctx = delayed_base.OptimizeContext() memo = ctx.memo - node_id = id(self) - if node_id in memo: - return memo[node_id] + if self in memo: + return memo[self] new_subdata = self.subdata.optimize(ctx) if new_subdata is self.subdata: new = self @@ -1488,7 +1486,7 @@ def optimize(self, ctx=None): new = new_subdata.as_xarray() if TRACE_OPTIMIZE: new._opt_logs.append('optimize DelayedAsXarray') - memo[node_id] = new + memo[self] = new return new @@ -1678,9 +1676,8 @@ def optimize(self, ctx=None): if ctx is None: ctx = delayed_base.OptimizeContext() memo = ctx.memo - node_id = id(self) - if node_id in memo: - return memo[node_id] + if self in memo: + return memo[self] new = copy.copy(self) new.subdata = self.subdata.optimize(ctx) @@ -1719,7 +1716,7 @@ def optimize(self, ctx=None): new = new._opt_absorb_overview() if TRACE_OPTIMIZE: new._opt_logs.append('optimize DelayedWarp') - memo[node_id] = new + memo[self] = new return new def _transform_from_subdata(self): @@ -2152,9 +2149,8 @@ def optimize(self, ctx=None): if ctx is None: ctx = delayed_base.OptimizeContext() memo = ctx.memo - node_id = id(self) - if node_id in memo: - return memo[node_id] + if self in memo: + return memo[self] new = copy.copy(self) new.subdata = self.subdata.optimize(ctx) @@ -2175,7 +2171,7 @@ def optimize(self, ctx=None): new = pushed if TRACE_OPTIMIZE: new._opt_logs.append('optimize DelayedDequantize') - memo[node_id] = new + memo[self] = new return new def _opt_dequant_before_other(self): @@ -2309,9 +2305,8 @@ def optimize(self, ctx=None): if ctx is None: ctx = delayed_base.OptimizeContext() memo = ctx.memo - node_id = id(self) - if node_id in memo: - return memo[node_id] + if self in memo: + return memo[self] new = copy.copy(self) new.subdata = self.subdata.optimize(ctx) @@ -2363,7 +2358,7 @@ def optimize(self, ctx=None): new = pushed if TRACE_OPTIMIZE: new._opt_logs.append('optimize crop') - memo[node_id] = new + memo[self] = new return new def _opt_fuse_crops(self): @@ -2641,9 +2636,8 @@ def optimize(self, ctx=None): if ctx is None: ctx = delayed_base.OptimizeContext() memo = ctx.memo - node_id = id(self) - if node_id in memo: - return memo[node_id] + if self in memo: + return memo[self] new = copy.copy(self) new.subdata = self.subdata.optimize(ctx) @@ -2669,7 +2663,7 @@ def optimize(self, ctx=None): new = pushed if TRACE_OPTIMIZE: new._opt_logs.append('optimize overview') - memo[node_id] = new + memo[self] = new return new def _transform_from_subdata(self):