Skip to content

Commit 2c3c2bc

Browse files
ctruedenclaude
andcommitted
Implement image.equation op
This implementation brings back the image.equation Op from ImageJ Ops, but using Parsington instead of JavaScript for deterministic evaluation. - Equation.java — the image.equation Op with a Computer signature (String, RandomAccessibleInterval<T>)). Parses the expression once into a SyntaxTree, then iterates pixels with a localizing cursor, updating position before each evaluation. Any per-pixel runtime error yields NaN. - EquationEvaluator.java — extends Parsington's DefaultTreeEvaluator. Exposes p[0..n], x/y/z/t, pi/e, and reflects every public-static numeric java.lang.Math method. - EquationTest.java — 10 tests covering constants, p[i], axis shorthands, single- and multi-arg math, pi/e, a 3D image, reproducibility, random() distribution, and the NaN-on-bad-expression contract. Adds parsington as a dependency to scijava-ops-image. Notable design notes: - parens(...) returns the single arg unwrapped, or an Object[] for multi-arg, so function() can pick Math overloads. - brackets(...) returns a marker BracketIndex so function(fn, args) can distinguish p[i] from cos(x). - Multi-arg function args came through as unresolved Variable tokens; resolved via Parsington's value(arg) before reflection. - Single-threaded for now — Parsington's evaluator is stateful, and the op is documented as a demo helper, so top performance is not critical. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 93e2ce5 commit 2c3c2bc

6 files changed

Lines changed: 565 additions & 1 deletion

File tree

scijava-ops-image/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,10 @@
281281

282282
<dependencies>
283283
<!-- SciJava dependencies -->
284+
<dependency>
285+
<groupId>org.scijava</groupId>
286+
<artifactId>parsington</artifactId>
287+
</dependency>
284288
<dependency>
285289
<groupId>org.scijava</groupId>
286290
<artifactId>scijava-collections</artifactId>

scijava-ops-image/src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
requires org.scijava.meta;
4646
requires org.scijava.ops.api;
4747
requires org.scijava.ops.spi;
48+
requires org.scijava.parsington;
4849
requires org.scijava.priority;
4950
requires org.scijava.progress;
5051
requires org.scijava.types;
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* #%L
3+
* Image processing operations for SciJava Ops.
4+
* %%
5+
* Copyright (C) 2014 - 2025 SciJava developers.
6+
* %%
7+
* Redistribution and use in source and binary forms, with or without
8+
* modification, are permitted provided that the following conditions are met:
9+
*
10+
* 1. Redistributions of source code must retain the above copyright notice,
11+
* this list of conditions and the following disclaimer.
12+
* 2. Redistributions in binary form must reproduce the above copyright notice,
13+
* this list of conditions and the following disclaimer in the documentation
14+
* and/or other materials provided with the distribution.
15+
*
16+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
20+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26+
* POSSIBILITY OF SUCH DAMAGE.
27+
* #L%
28+
*/
29+
30+
package org.scijava.ops.image.image.equation;
31+
32+
import net.imglib2.RandomAccessibleInterval;
33+
import net.imglib2.type.numeric.RealType;
34+
import net.imglib2.view.Views;
35+
36+
import org.scijava.parsington.ExpressionParser;
37+
import org.scijava.parsington.SyntaxTree;
38+
39+
/**
40+
* Computes an image from an equation evaluated at each pixel position.
41+
* <p>
42+
* The expression is parsed by the
43+
* <a href="https://github.com/scijava/parsington">Parsington</a> library; see
44+
* {@link EquationEvaluator} for the set of supported variables, constants, and
45+
* functions. Common examples:
46+
* </p>
47+
* <pre>
48+
* cos(0.1*p[0]) + sin(0.1*p[1])
49+
* sqrt(x*x + y*y)
50+
* 128 + 127*sin(0.05*x + 0.05*y)
51+
* </pre>
52+
* <p>
53+
* Because evaluation is purely a function of the input expression and the
54+
* pixel position, results are reproducible: re-running the same expression
55+
* over an equally sized output always yields identical pixel values. Even
56+
* {@code random()} is deterministic — it returns a value in {@code [0, 1)}
57+
* derived by hashing the pixel position together with a fixed seed.
58+
* </p>
59+
* <p>
60+
* Note that this op is rather slow; it is intended primarily for
61+
* demonstration purposes, and for easily generating small synthetic images
62+
* for testing other ops.
63+
* </p>
64+
*
65+
* @author Curtis Rueden
66+
*/
67+
public final class Equation {
68+
69+
private Equation() {
70+
// NB: utility class.
71+
}
72+
73+
/**
74+
* Fills {@code output} by evaluating {@code expression} at each pixel
75+
* position.
76+
*
77+
* @param expression a math expression in the syntax supported by
78+
* {@link EquationEvaluator}.
79+
* @param output the image buffer to fill.
80+
* @implNote op names='image.equation', type=Computer
81+
*/
82+
public static <T extends RealType<T>> void equation(final String expression,
83+
final RandomAccessibleInterval<T> output)
84+
{
85+
final SyntaxTree tree = new ExpressionParser().parseTree(expression);
86+
final EquationEvaluator evaluator = new EquationEvaluator(0L);
87+
final int n = output.numDimensions();
88+
final long[] pos = new long[n];
89+
evaluator.position(pos);
90+
91+
final var cursor = Views.iterable(output).localizingCursor();
92+
while (cursor.hasNext()) {
93+
cursor.fwd();
94+
cursor.localize(pos);
95+
double value;
96+
try {
97+
final Object result = evaluator.evaluate(tree);
98+
value = (result instanceof Number) ? ((Number) result).doubleValue()
99+
: Double.NaN;
100+
}
101+
catch (final RuntimeException exc) {
102+
value = Double.NaN;
103+
}
104+
cursor.get().setReal(value);
105+
}
106+
}
107+
}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/*
2+
* #%L
3+
* Image processing operations for SciJava Ops.
4+
* %%
5+
* Copyright (C) 2014 - 2025 SciJava developers.
6+
* %%
7+
* Redistribution and use in source and binary forms, with or without
8+
* modification, are permitted provided that the following conditions are met:
9+
*
10+
* 1. Redistributions of source code must retain the above copyright notice,
11+
* this list of conditions and the following disclaimer.
12+
* 2. Redistributions in binary form must reproduce the above copyright notice,
13+
* this list of conditions and the following disclaimer in the documentation
14+
* and/or other materials provided with the distribution.
15+
*
16+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
20+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26+
* POSSIBILITY OF SUCH DAMAGE.
27+
* #L%
28+
*/
29+
30+
package org.scijava.ops.image.image.equation;
31+
32+
import java.lang.reflect.Method;
33+
import java.lang.reflect.Modifier;
34+
import java.util.ArrayList;
35+
import java.util.Arrays;
36+
import java.util.HashMap;
37+
import java.util.List;
38+
import java.util.Map;
39+
40+
import org.scijava.parsington.Variable;
41+
import org.scijava.parsington.eval.DefaultTreeEvaluator;
42+
43+
/**
44+
* A Parsington {@code TreeEvaluator} tailored for evaluating equations over
45+
* pixel positions.
46+
* <p>
47+
* Beyond the standard math operators, this evaluator exposes:
48+
* <ul>
49+
* <li>{@code p} — the position array; access dimensions via {@code p[0]},
50+
* {@code p[1]}, …</li>
51+
* <li>{@code x}, {@code y}, {@code z}, {@code t} — shorthand for the first
52+
* four dimensions, where applicable.</li>
53+
* <li>{@code pi}, {@code PI}, {@code e}, {@code E} — math constants.</li>
54+
* <li>All {@code public static} numeric methods of {@link Math} (e.g.
55+
* {@code sin}, {@code cos}, {@code exp}, {@code sqrt}, {@code pow},
56+
* {@code atan2}, {@code min}, {@code max}, {@code floor}, …).</li>
57+
* <li>{@code random()} — deterministic pseudo-random draw in {@code [0, 1)},
58+
* seeded by both an evaluator-wide seed and the current position. The same
59+
* position always yields the same value, regardless of iteration order.</li>
60+
* </ul>
61+
*
62+
* @author Curtis Rueden
63+
*/
64+
public class EquationEvaluator extends DefaultTreeEvaluator {
65+
66+
private static final Map<String, List<Method>> MATH_METHODS = indexMath();
67+
68+
/** Per-axis shorthand names, in order. */
69+
private static final String[] AXIS_NAMES = { "x", "y", "z", "t" };
70+
71+
private final long seed;
72+
73+
/** Current pixel position; updated by the driver loop before each evaluate. */
74+
private long[] pos = new long[0];
75+
76+
public EquationEvaluator(final long seed) {
77+
this.seed = seed;
78+
setStrict(false);
79+
}
80+
81+
/** Updates the position for the next evaluation. */
82+
public void position(final long[] newPos) {
83+
this.pos = newPos;
84+
}
85+
86+
// -- Evaluator overrides --
87+
88+
@Override
89+
public Object get(final String name) {
90+
switch (name) {
91+
case "p":
92+
return pos;
93+
case "pi":
94+
case "PI":
95+
return Math.PI;
96+
case "e":
97+
case "E":
98+
return Math.E;
99+
default:
100+
}
101+
for (int d = 0; d < AXIS_NAMES.length && d < pos.length; d++) {
102+
if (AXIS_NAMES[d].equals(name)) return pos[d];
103+
}
104+
return super.get(name);
105+
}
106+
107+
@Override
108+
public Object brackets(final Object... args) {
109+
// Wrap bracket indices so function() can distinguish p[i] from cos(x).
110+
return new BracketIndex(args);
111+
}
112+
113+
@Override
114+
public Object parens(final Object... args) {
115+
// Single-arg parens unwrap; multi-arg propagate as an array so function()
116+
// can pick the right Math overload.
117+
if (args.length == 1) return args[0];
118+
return args;
119+
}
120+
121+
@Override
122+
public Object function(final Object fn, final Object args) {
123+
final String name = functionName(fn);
124+
125+
// Array indexing: p[i], or any variable holding an array.
126+
if (args instanceof BracketIndex) {
127+
final Object[] idxs = ((BracketIndex) args).indices;
128+
final Object container = (fn instanceof Variable) ? get(name) : fn;
129+
return indexInto(container, idxs);
130+
}
131+
132+
// Special-case deterministic random().
133+
if ("random".equals(name)) return deterministicRandom();
134+
135+
// Otherwise, dispatch to java.lang.Math.
136+
final Object[] rawArgs = (args instanceof Object[]) ? (Object[]) args
137+
: new Object[] { args };
138+
final Object[] callArgs = new Object[rawArgs.length];
139+
for (int i = 0; i < rawArgs.length; i++)
140+
callArgs[i] = value(rawArgs[i]);
141+
final Object result = invokeMath(name, callArgs);
142+
if (result != null) return result;
143+
144+
// Fallback to default behavior (will likely fail loudly).
145+
return super.function(fn, args);
146+
}
147+
148+
// -- Helpers --
149+
150+
private static String functionName(final Object fn) {
151+
if (fn instanceof Variable) return ((Variable) fn).getToken();
152+
return String.valueOf(fn);
153+
}
154+
155+
private static Object indexInto(final Object container, final Object[] idxs) {
156+
if (idxs.length != 1) {
157+
throw new IllegalArgumentException(
158+
"Only 1-D indexing is supported; got " + idxs.length + " indices");
159+
}
160+
final int i = ((Number) idxs[0]).intValue();
161+
if (container instanceof long[]) return ((long[]) container)[i];
162+
if (container instanceof double[]) return ((double[]) container)[i];
163+
if (container instanceof int[]) return (long) ((int[]) container)[i];
164+
if (container instanceof Object[]) return ((Object[]) container)[i];
165+
if (container instanceof List) return ((List<?>) container).get(i);
166+
throw new IllegalArgumentException("Cannot index into " + (container == null
167+
? "null" : container.getClass().getName()));
168+
}
169+
170+
private double deterministicRandom() {
171+
long h = seed;
172+
for (int i = 0; i < pos.length; i++) {
173+
h = splitMix64(h ^ splitMix64(pos[i] + 0x9E3779B97F4A7C15L * (i + 1)));
174+
}
175+
// Upper 53 bits → [0, 1)
176+
return (h >>> 11) * 0x1.0p-53;
177+
}
178+
179+
private static long splitMix64(long z) {
180+
z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L;
181+
z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL;
182+
return z ^ (z >>> 31);
183+
}
184+
185+
private static Object invokeMath(final String name, final Object[] args) {
186+
final List<Method> candidates = MATH_METHODS.get(name);
187+
if (candidates == null) return null;
188+
// Prefer the (double, double, ...) overload for floating-point math.
189+
Method best = null;
190+
for (final Method m : candidates) {
191+
if (m.getParameterCount() != args.length) continue;
192+
if (best == null || prefersDouble(m, best)) best = m;
193+
}
194+
if (best == null) return null;
195+
final Class<?>[] paramTypes = best.getParameterTypes();
196+
final Object[] coerced = new Object[args.length];
197+
for (int i = 0; i < args.length; i++) {
198+
coerced[i] = coerce(args[i], paramTypes[i]);
199+
}
200+
try {
201+
return best.invoke(null, coerced);
202+
}
203+
catch (final ReflectiveOperationException exc) {
204+
throw new IllegalStateException("Failed to invoke Math." + name, exc);
205+
}
206+
}
207+
208+
private static boolean prefersDouble(final Method candidate,
209+
final Method incumbent)
210+
{
211+
final boolean candAllDouble = allDoubleParams(candidate);
212+
final boolean incAllDouble = allDoubleParams(incumbent);
213+
return candAllDouble && !incAllDouble;
214+
}
215+
216+
private static boolean allDoubleParams(final Method m) {
217+
for (final Class<?> p : m.getParameterTypes()) {
218+
if (p != double.class) return false;
219+
}
220+
return true;
221+
}
222+
223+
private static Object coerce(final Object value, final Class<?> target) {
224+
if (target == double.class) return ((Number) value).doubleValue();
225+
if (target == float.class) return ((Number) value).floatValue();
226+
if (target == long.class) return ((Number) value).longValue();
227+
if (target == int.class) return ((Number) value).intValue();
228+
return value;
229+
}
230+
231+
private static Map<String, List<Method>> indexMath() {
232+
final Map<String, List<Method>> map = new HashMap<>();
233+
for (final Method m : Math.class.getMethods()) {
234+
final int mods = m.getModifiers();
235+
if (!Modifier.isPublic(mods) || !Modifier.isStatic(mods)) continue;
236+
if ("random".equals(m.getName())) continue; // handled deterministically
237+
if (!Number.class.isAssignableFrom(boxed(m.getReturnType()))) continue;
238+
boolean numericArgs = true;
239+
for (final Class<?> p : m.getParameterTypes()) {
240+
if (!Number.class.isAssignableFrom(boxed(p))) {
241+
numericArgs = false;
242+
break;
243+
}
244+
}
245+
if (!numericArgs) continue;
246+
map.computeIfAbsent(m.getName(), k -> new ArrayList<>()).add(m);
247+
}
248+
return map;
249+
}
250+
251+
private static Class<?> boxed(final Class<?> c) {
252+
if (c == double.class) return Double.class;
253+
if (c == float.class) return Float.class;
254+
if (c == long.class) return Long.class;
255+
if (c == int.class) return Integer.class;
256+
if (c == short.class) return Short.class;
257+
if (c == byte.class) return Byte.class;
258+
return c;
259+
}
260+
261+
private static final class BracketIndex {
262+
263+
final Object[] indices;
264+
265+
BracketIndex(final Object[] indices) {
266+
this.indices = Arrays.copyOf(indices, indices.length);
267+
}
268+
}
269+
}

0 commit comments

Comments
 (0)