From 9a467e4b3568e846288cb5a59668c878ab929771 Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Fri, 27 Mar 2026 07:54:11 -0500 Subject: [PATCH 1/9] add primitives for vertical- and horizontal-symmetry (needed in the library due to performance reasons). Claude Sonnet 4.6 and Gemini 3.1 Thinking did the heavy lifting. --- src/js/trove/image-lib.js | 116 +++++++++++++++++-------- src/js/trove/internal-image-typed.js | 8 +- src/js/trove/internal-image-untyped.js | 14 +-- src/js/trove/make-image.js | 14 +++ tests/pyret/tests/test-images.arr | 47 ++++++++++ 5 files changed, 156 insertions(+), 43 deletions(-) diff --git a/src/js/trove/image-lib.js b/src/js/trove/image-lib.js index f5677f135..516a11428 100644 --- a/src/js/trove/image-lib.js +++ b/src/js/trove/image-lib.js @@ -741,14 +741,14 @@ this.src = src; this.ariaText = " video file from "+decodeURIComponent(src).slice(16); if (rawVideo) { - this.video = rawVideo; - this.width = self.video.videoWidth; - this.height = self.video.videoHeight; - this.video.volume = 1; - this.video.poster = "http://www.wescheme.org/images/broken.png"; - this.video.autoplay = true; + this.video = rawVideo; + this.width = self.video.videoWidth; + this.height = self.video.videoHeight; + this.video.volume = 1; + this.video.poster = "http://www.wescheme.org/images/broken.png"; + this.video.autoplay = true; this.video.autobuffer=true; - this.video.loop = true; + this.video.loop = true; this.video.play(); } else { // fixme: we may want to do something blocking here for @@ -758,12 +758,12 @@ this.video = document.createElement('video'); this.video.src = src; this.video.addEventListener('canplay', function() { - this.width = self.video.videoWidth; - this.height = self.video.videoHeight; - this.video.poster = "http://www.wescheme.org/images/broken.png"; - this.video.autoplay = true; + this.width = self.video.videoWidth; + this.height = self.video.videoHeight; + this.video.poster = "http://www.wescheme.org/images/broken.png"; + this.video.autoplay = true; this.video.autobuffer=true; - this.video.loop = true; + this.video.loop = true; this.video.play(); }); this.video.addEventListener('error', function(e) { @@ -849,7 +849,7 @@ var blank = makeCanvas(0, 0); return blank; } - + var ctx = canvas.getContext("2d"); var width = canvas.width; var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); @@ -887,7 +887,7 @@ // start in a coordinate system with origin at top/left corners var x1 = 0, y1 = 0, x2 = 0, y2 = 0; var anchor1, anchor2; - + // compute the x1/y1 and x2/y2 offsets, relative to the top/left of img1/img2: switch(placeX1.toLowerCase()) { case "left": x1 -= 0; anchor1 = "-left"; break; @@ -919,10 +919,10 @@ case "bottom": y2 -= img2.height; anchor2 = "bottom" + anchor2; break; default: throw new Error("Unknown YPlace option for image 2: " + placeY2); } - + // Next, offset x2/y2 by the given offsetX/Y x2 += offsetX; y2 += offsetY; - + // Translate both offset pairs by the smaller of the half-dimensions var xMax = Math.max(img1.width, img2.width); @@ -940,7 +940,7 @@ var i, v1 = img1.getVertices(), v2 = img2.getVertices(), xs = [], ys = []; v1 = v1.map(function(v){ return {x: v.x + x1, y: v.y + y1}; }); v2 = v2.map(function(v){ return {x: v.x + x2, y: v.y + y2}; }); - + // store the vertices as something private, so this.getVertices() will still return undefined this._vertices = v1.concat(v2); @@ -1277,7 +1277,7 @@ this.width === other.width && this.height === other.height && this.direction === other.direction && - imageEquals(this.img, other.img) ) + imageEquals(this.img, other.img) ) || BaseImage.prototype.equals.call(this, other); }; @@ -1390,7 +1390,7 @@ vertices[v].y = jsnums.toFixnum(vertices[v].y); vertices[v].y *= -1; } - + this.width = findWidth(vertices); this.height = findHeight(vertices); this.style = style; @@ -1408,7 +1408,7 @@ this.ariaText = " a"+colorToSpokenString(color,style) + ", polygon with "+vertices.length+" points"; }; PointPolygonImage.prototype = heir(BaseImage.prototype); - + // We don't trust ctx.measureText, since (a) it's buggy and (b) it doesn't measure height // based off of https://stackoverflow.com/a/9847841/783424, @@ -1432,7 +1432,7 @@ var block = document.createElement("div"); block.style.display = "inline-block"; block.style.width = 1; block.style.height = 0; - block.style.margin = "0"; block.style.padding = "0"; + block.style.margin = "0"; block.style.padding = "0"; var div = document.createElement("div"); div.style.margin = "0"; div.style.padding = "0"; div.append(text, block); @@ -1754,11 +1754,57 @@ this.pinholeX = Math.abs(x) / 2; this.pinholeY = Math.abs(y) / 2; this.ariaText = " a" + colorToSpokenString(color,'solid') + " line of width "+x+" and height "+y; - + }; LineImage.prototype = heir(BaseImage.prototype); + var verticalSymmetry = function(img) { + var width = img.getWidth(); + var height = img.getHeight(); + var halfW = Math.floor(width / 2); + if (halfW === 0 || height === 0) { return 1; } + var canvas = makeCanvas(width, height); + img.render(canvas.getContext("2d")); + var data = canvas.getContext("2d").getImageData(0, 0, width, height).data; + var sumSq = 0, count = 0; + for (var y = 0; y < height; y++) { + for (var x = 0; x < halfW; x++) { + var i1 = (y * width + x) * 4; + var i2 = (y * width + (width - 1 - x)) * 4; + for (var ch = 0; ch < 4; ch++) { + var d = data[i1 + ch] - data[i2 + ch]; + sumSq += d * d; + } + count += 4; + } + } + return 1 - Math.sqrt(sumSq / count) / 255; + }; + + var horizontalSymmetry = function(img) { + var width = img.getWidth(); + var height = img.getHeight(); + var halfH = Math.floor(height / 2); + if (width === 0 || halfH === 0) { return 1; } + var canvas = makeCanvas(width, height); + img.render(canvas.getContext("2d")); + var data = canvas.getContext("2d").getImageData(0, 0, width, height).data; + var sumSq = 0, count = 0; + for (var y = 0; y < halfH; y++) { + for (var x = 0; x < width; x++) { + var i1 = (y * width + x) * 4; + var i2 = ((height - 1 - y) * width + x) * 4; + for (var ch = 0; ch < 4; ch++) { + var d = data[i1 + ch] - data[i2 + ch]; + sumSq += d * d; + } + count += 4; + } + } + return 1 - Math.sqrt(sumSq / count) / 255; + }; + var colorAtPosition = function(img, x, y) { var width = img.getWidth(), height = img.getHeight(), @@ -1902,26 +1948,26 @@ var isSceneImage = function(x) { return x instanceof SceneImage; }; var isCircleImage = function(x) { return x instanceof EllipseImage && x.width === x.height; }; - var isStarImage = function(x) { return x instanceof StarImage; }; + var isStarImage = function(x) { return x instanceof StarImage; }; var isRectangleImage=function(x) { return x instanceof RectangleImage; }; var isRegularPolygonImage = function(x) { return x instanceof RegularPolygonImage; }; var isPointPolygonImage = function(x) { return x instanceof PointPolygonImage; }; var isRhombusImage = function(x) { return x instanceof RhombusImage; }; - var isSquareImage = function(x) { return x instanceof SquareImage; }; + var isSquareImage = function(x) { return x instanceof SquareImage; }; var isTriangleImage= function(x) { return x instanceof TriangleImage; }; var isWedgeImage = function(x) { return x instanceof WedgeImage; }; var isEllipseImage = function(x) { return x instanceof EllipseImage; }; - var isLineImage = function(x) { return x instanceof LineImage; }; + var isLineImage = function(x) { return x instanceof LineImage; }; var isOverlayImage = function(x) { return x instanceof OverlayImage; }; - var isRotateImage = function(x) { return x instanceof RotateImage; }; - var isScaleImage = function(x) { return x instanceof ScaleImage; }; - var isCropImage = function(x) { return x instanceof CropImage; }; - var isFrameImage = function(x) { return x instanceof FrameImage; }; - var isPinholeImage = function(x) { return x instanceof PinholeImage; }; - var isFlipImage = function(x) { return x instanceof FlipImage; }; - var isTextImage = function(x) { return x instanceof TextImage; }; - var isFileImage = function(x) { return x instanceof FileImage; }; - var isFileVideo = function(x) { return x instanceof FileVideo; }; + var isRotateImage = function(x) { return x instanceof RotateImage; }; + var isScaleImage = function(x) { return x instanceof ScaleImage; }; + var isCropImage = function(x) { return x instanceof CropImage; }; + var isFrameImage = function(x) { return x instanceof FrameImage; }; + var isPinholeImage = function(x) { return x instanceof PinholeImage; }; + var isFlipImage = function(x) { return x instanceof FlipImage; }; + var isTextImage = function(x) { return x instanceof TextImage; }; + var isFileImage = function(x) { return x instanceof FileImage; }; + var isFileVideo = function(x) { return x instanceof FileVideo; }; /////////////////////////////////////////////////////////////// // Exports @@ -1989,6 +2035,8 @@ colorAtPosition: colorAtPosition, imageToColorList: imageToColorList, + verticalSymmetry: verticalSymmetry, + horizontalSymmetry: horizontalSymmetry, colorListToImage: colorListToImage, trimCanvas: trimCanvas, trimImageToCanvas: trimImageToCanvas, diff --git a/src/js/trove/internal-image-typed.js b/src/js/trove/internal-image-typed.js index 84226d468..87ab42e78 100644 --- a/src/js/trove/internal-image-typed.js +++ b/src/js/trove/internal-image-typed.js @@ -154,6 +154,8 @@ "image-width": ["arrow", ["Image"], "Number"], "image-height": ["arrow", ["Image"], "Number"], "image-baseline": ["arrow", ["Image"], "Number"], + "vertical-symmetry": ["arrow", ["Image"], "Number"], + "horizontal-symmetry": ["arrow", ["Image"], "Number"], "image-pinhole-x": ["arrow", ["Image"], "Number"], "image-pinhole-y": ["arrow", ["Image"], "Number"], "name-to-color": ["arrow", ["String"], "OptColor"], @@ -223,7 +225,7 @@ } return { x: jsnums.toFixnum(gf(val, "x")), y: jsnums.toFixnum(gf(val, "y")) }; }; - + const ANNOTS = { annString: runtime.String, annNumber: runtime.Number, @@ -264,7 +266,7 @@ "ff-symbol": function(_) { return "symbol"; }, "ff-system": function(_) { return "system"; }, }); - }, + }, annFontStyle: image.annFontStyle, unwrapFontStyle: function(fs) { return runtime.ffi.cases(pyAlwaysTrue, "FontStyle", fs, { @@ -358,4 +360,4 @@ "Scene": runtime.makePrimitiveAnn("Scene", checkScenePred) }); } -}) +}) \ No newline at end of file diff --git a/src/js/trove/internal-image-untyped.js b/src/js/trove/internal-image-untyped.js index 8103ca5c9..0bd9c7dcc 100644 --- a/src/js/trove/internal-image-untyped.js +++ b/src/js/trove/internal-image-untyped.js @@ -143,6 +143,8 @@ "color-list-to-bitmap": ["arrow", ["LoC", "Number", "Number"], "Image"], "image-width": ["arrow", ["Image"], "Number"], "image-height": ["arrow", ["Image"], "Number"], + "vertical-symmetry": ["arrow", ["Image"], "Number"], + "horizontal-symmetry": ["arrow", ["Image"], "Number"], "image-baseline": ["arrow", ["Image"], "Number"], "image-pinhole-x": ["arrow", ["Image"], "Number"], "image-pinhole-y": ["arrow", ["Image"], "Number"], @@ -194,7 +196,7 @@ }; var isPlaceY = function(x) { return (isString(x) && - (x.toString().toLowerCase() == "top" || + (x.toString().toLowerCase() == "top" || x.toString().toLowerCase() == "bottom" || x.toString().toLowerCase() == "baseline" || x.toString().toLowerCase() == "center" || @@ -284,7 +286,7 @@ x.toString().toLowerCase() == "modern" || x.toString().toLowerCase() == "symbol" || x.toString().toLowerCase() == "system")) - || (x === false); // false is also acceptable + || (x === false); // false is also acceptable }), unwrapFontFamily: identity, annFontStyle: ann("Font Style (\"normal\", \"italic\", or \"slant\")", function(x){ @@ -292,7 +294,7 @@ (x.toString().toLowerCase() == "normal" || x.toString().toLowerCase() == "italic" || x.toString().toLowerCase() == "slant")) - || (x === false); // false is also acceptable + || (x === false); // false is also acceptable }), unwrapFontStyle: identity, annFontWeight: ann("Font Weight", function(x){ @@ -300,7 +302,7 @@ (x.toString().toLowerCase() == "normal" || x.toString().toLowerCase() == "bold" || x.toString().toLowerCase() == "light")) - || (x === false); // false is also acceptable + || (x === false); // false is also acceptable }), unwrapFontWeight: identity, annPlaceX: ann("X Place (\"left\", \"middle\", \"center\", \"pinhole\", or \"right\")", isPlaceX), @@ -367,10 +369,10 @@ }); - + return runtime.makeModuleReturn(values, { "Image": image.Image, "Scene": runtime.makePrimitiveAnn("Scene", checkScenePred) }); } -}) +}) \ No newline at end of file diff --git a/src/js/trove/make-image.js b/src/js/trove/make-image.js index 3ed32b12e..957637d0c 100644 --- a/src/js/trove/make-image.js +++ b/src/js/trove/make-image.js @@ -1273,6 +1273,20 @@ return runtime.wrap(img.getHeight()); }); + f("vertical-symmetry", function(maybeImg) { + checkArity(1, arguments, "vertical-symmetry", false); + c1("vertical-symmetry", maybeImg, annImage); + var img = unwrapImage(maybeImg); + return runtime.wrap(image.verticalSymmetry(img)); + }); + + f("horizontal-symmetry", function(maybeImg) { + checkArity(1, arguments, "horizontal-symmetry", false); + c1("horizontal-symmetry", maybeImg, annImage); + var img = unwrapImage(maybeImg); + return runtime.wrap(image.horizontalSymmetry(img)); + }); + f("image-baseline", function(maybeImg) { checkArity(1, arguments, "image-baseline", false); c1("image-baseline", maybeImg, annImage); diff --git a/tests/pyret/tests/test-images.arr b/tests/pyret/tests/test-images.arr index 63c1d8b84..b53ad2ec4 100644 --- a/tests/pyret/tests/test-images.arr +++ b/tests/pyret/tests/test-images.arr @@ -253,3 +253,50 @@ logo2 = image-file("pyret-logo-copy.png") check: logo is logo2 end + + +check "Symmetry": + fun less-than(n1, n2): n1 < n2 end + + # A simple square should be perfectly symmetrical in both directions + perfect-sym = square(50, mode-solid, blue) + vertical-symmetry(perfect-sym) is%(within-abs(0.01)) 1 + horizontal-symmetry(perfect-sym) is%(within-abs(0.01)) 1 + + # Two different-colored shapes beside each other break vertical symmetry (left vs right) + # but maintain horizontal symmetry (top vs bottom) + asym-vert = beside(square(20, mode-solid, red), square(20, mode-solid, blue)) + vertical-symmetry(asym-vert) is%(less-than) 1 + horizontal-symmetry(asym-vert) is%(within-abs(0.01)) 1 + + # Two different-colored shapes above each other break horizontal symmetry (top vs bottom) + # but maintain vertical symmetry (left vs right) + asym-horiz = above(square(20, mode-solid, red), square(20, mode-solid, blue)) + vertical-symmetry(asym-horiz) is%(within-abs(0.01)) 1 + horizontal-symmetry(asym-horiz) is%(less-than) 1 + + # A right triangle is typically asymmetrical in both dimensions + rt = right-triangle(40, 50, mode-solid, black) + (vertical-symmetry(rt) < 1) is%(within-abs(0.01)) true + (horizontal-symmetry(rt) < 1) is%(within-abs(0.01)) true + + # Empty scenes should be perfectly symmetrical + empty-sn = empty-scene(50, 50) + vertical-symmetry(empty-sn) is%(within-abs(0.01)) 1 + horizontal-symmetry(empty-sn) is%(within-abs(0.01)) 1 + + # Truly empty/0-width or 0-height images should also be symmetrical + empty-width = rectangle(0, 100, mode-solid, black) + vertical-symmetry(empty-width) is%(within-abs(0.01)) 1 + horizontal-symmetry(empty-width) is%(within-abs(0.01)) 1 + + # Create an asymmetrical image to test cropping + asym-base = beside(square(40, mode-solid, red), square(40, mode-solid, blue)) + vertical-symmetry(asym-base) is%(less-than) 1 + horizontal-symmetry(asym-base) is%(within-abs(0.01)) 1 + + # Cropping just the left side (the red square) restores vertical symmetry + cropped-sym = crop(0, 0, 40, 40, asym-base) + vertical-symmetry(cropped-sym) is%(within-abs(0.01)) 1 + horizontal-symmetry(cropped-sym) is%(within-abs(0.01)) 1 +end \ No newline at end of file From 81cb2abd45ab9877242657fdf5d736d6159e4a16 Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Fri, 27 Mar 2026 08:37:50 -0500 Subject: [PATCH 2/9] expose new primitives --- src/arr/trove/starter2024.arr | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/arr/trove/starter2024.arr b/src/arr/trove/starter2024.arr index 91d59d48d..acea39d40 100644 --- a/src/arr/trove/starter2024.arr +++ b/src/arr/trove/starter2024.arr @@ -235,6 +235,8 @@ provide from I: underlay-align-list, underlay-list, underlay-xy, + vertical-symmetry, + horizontal-symmetry, wedge, x-center, x-left, From d67b4397d925d2c95df9e77a942eec5f88aadc42 Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Fri, 27 Mar 2026 10:29:18 -0500 Subject: [PATCH 3/9] Revert "add primitives for vertical- and horizontal-symmetry (needed in the library due to performance reasons). Claude Sonnet 4.6 and Gemini 3.1 Thinking did the heavy lifting." This reverts commit 9a467e4b3568e846288cb5a59668c878ab929771. --- src/js/trove/image-lib.js | 116 ++++++++----------------- src/js/trove/internal-image-typed.js | 8 +- src/js/trove/internal-image-untyped.js | 14 ++- src/js/trove/make-image.js | 14 --- tests/pyret/tests/test-images.arr | 47 ---------- 5 files changed, 43 insertions(+), 156 deletions(-) diff --git a/src/js/trove/image-lib.js b/src/js/trove/image-lib.js index 516a11428..f5677f135 100644 --- a/src/js/trove/image-lib.js +++ b/src/js/trove/image-lib.js @@ -741,14 +741,14 @@ this.src = src; this.ariaText = " video file from "+decodeURIComponent(src).slice(16); if (rawVideo) { - this.video = rawVideo; - this.width = self.video.videoWidth; - this.height = self.video.videoHeight; - this.video.volume = 1; - this.video.poster = "http://www.wescheme.org/images/broken.png"; - this.video.autoplay = true; + this.video = rawVideo; + this.width = self.video.videoWidth; + this.height = self.video.videoHeight; + this.video.volume = 1; + this.video.poster = "http://www.wescheme.org/images/broken.png"; + this.video.autoplay = true; this.video.autobuffer=true; - this.video.loop = true; + this.video.loop = true; this.video.play(); } else { // fixme: we may want to do something blocking here for @@ -758,12 +758,12 @@ this.video = document.createElement('video'); this.video.src = src; this.video.addEventListener('canplay', function() { - this.width = self.video.videoWidth; - this.height = self.video.videoHeight; - this.video.poster = "http://www.wescheme.org/images/broken.png"; - this.video.autoplay = true; + this.width = self.video.videoWidth; + this.height = self.video.videoHeight; + this.video.poster = "http://www.wescheme.org/images/broken.png"; + this.video.autoplay = true; this.video.autobuffer=true; - this.video.loop = true; + this.video.loop = true; this.video.play(); }); this.video.addEventListener('error', function(e) { @@ -849,7 +849,7 @@ var blank = makeCanvas(0, 0); return blank; } - + var ctx = canvas.getContext("2d"); var width = canvas.width; var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); @@ -887,7 +887,7 @@ // start in a coordinate system with origin at top/left corners var x1 = 0, y1 = 0, x2 = 0, y2 = 0; var anchor1, anchor2; - + // compute the x1/y1 and x2/y2 offsets, relative to the top/left of img1/img2: switch(placeX1.toLowerCase()) { case "left": x1 -= 0; anchor1 = "-left"; break; @@ -919,10 +919,10 @@ case "bottom": y2 -= img2.height; anchor2 = "bottom" + anchor2; break; default: throw new Error("Unknown YPlace option for image 2: " + placeY2); } - + // Next, offset x2/y2 by the given offsetX/Y x2 += offsetX; y2 += offsetY; - + // Translate both offset pairs by the smaller of the half-dimensions var xMax = Math.max(img1.width, img2.width); @@ -940,7 +940,7 @@ var i, v1 = img1.getVertices(), v2 = img2.getVertices(), xs = [], ys = []; v1 = v1.map(function(v){ return {x: v.x + x1, y: v.y + y1}; }); v2 = v2.map(function(v){ return {x: v.x + x2, y: v.y + y2}; }); - + // store the vertices as something private, so this.getVertices() will still return undefined this._vertices = v1.concat(v2); @@ -1277,7 +1277,7 @@ this.width === other.width && this.height === other.height && this.direction === other.direction && - imageEquals(this.img, other.img) ) + imageEquals(this.img, other.img) ) || BaseImage.prototype.equals.call(this, other); }; @@ -1390,7 +1390,7 @@ vertices[v].y = jsnums.toFixnum(vertices[v].y); vertices[v].y *= -1; } - + this.width = findWidth(vertices); this.height = findHeight(vertices); this.style = style; @@ -1408,7 +1408,7 @@ this.ariaText = " a"+colorToSpokenString(color,style) + ", polygon with "+vertices.length+" points"; }; PointPolygonImage.prototype = heir(BaseImage.prototype); - + // We don't trust ctx.measureText, since (a) it's buggy and (b) it doesn't measure height // based off of https://stackoverflow.com/a/9847841/783424, @@ -1432,7 +1432,7 @@ var block = document.createElement("div"); block.style.display = "inline-block"; block.style.width = 1; block.style.height = 0; - block.style.margin = "0"; block.style.padding = "0"; + block.style.margin = "0"; block.style.padding = "0"; var div = document.createElement("div"); div.style.margin = "0"; div.style.padding = "0"; div.append(text, block); @@ -1754,57 +1754,11 @@ this.pinholeX = Math.abs(x) / 2; this.pinholeY = Math.abs(y) / 2; this.ariaText = " a" + colorToSpokenString(color,'solid') + " line of width "+x+" and height "+y; - + }; LineImage.prototype = heir(BaseImage.prototype); - var verticalSymmetry = function(img) { - var width = img.getWidth(); - var height = img.getHeight(); - var halfW = Math.floor(width / 2); - if (halfW === 0 || height === 0) { return 1; } - var canvas = makeCanvas(width, height); - img.render(canvas.getContext("2d")); - var data = canvas.getContext("2d").getImageData(0, 0, width, height).data; - var sumSq = 0, count = 0; - for (var y = 0; y < height; y++) { - for (var x = 0; x < halfW; x++) { - var i1 = (y * width + x) * 4; - var i2 = (y * width + (width - 1 - x)) * 4; - for (var ch = 0; ch < 4; ch++) { - var d = data[i1 + ch] - data[i2 + ch]; - sumSq += d * d; - } - count += 4; - } - } - return 1 - Math.sqrt(sumSq / count) / 255; - }; - - var horizontalSymmetry = function(img) { - var width = img.getWidth(); - var height = img.getHeight(); - var halfH = Math.floor(height / 2); - if (width === 0 || halfH === 0) { return 1; } - var canvas = makeCanvas(width, height); - img.render(canvas.getContext("2d")); - var data = canvas.getContext("2d").getImageData(0, 0, width, height).data; - var sumSq = 0, count = 0; - for (var y = 0; y < halfH; y++) { - for (var x = 0; x < width; x++) { - var i1 = (y * width + x) * 4; - var i2 = ((height - 1 - y) * width + x) * 4; - for (var ch = 0; ch < 4; ch++) { - var d = data[i1 + ch] - data[i2 + ch]; - sumSq += d * d; - } - count += 4; - } - } - return 1 - Math.sqrt(sumSq / count) / 255; - }; - var colorAtPosition = function(img, x, y) { var width = img.getWidth(), height = img.getHeight(), @@ -1948,26 +1902,26 @@ var isSceneImage = function(x) { return x instanceof SceneImage; }; var isCircleImage = function(x) { return x instanceof EllipseImage && x.width === x.height; }; - var isStarImage = function(x) { return x instanceof StarImage; }; + var isStarImage = function(x) { return x instanceof StarImage; }; var isRectangleImage=function(x) { return x instanceof RectangleImage; }; var isRegularPolygonImage = function(x) { return x instanceof RegularPolygonImage; }; var isPointPolygonImage = function(x) { return x instanceof PointPolygonImage; }; var isRhombusImage = function(x) { return x instanceof RhombusImage; }; - var isSquareImage = function(x) { return x instanceof SquareImage; }; + var isSquareImage = function(x) { return x instanceof SquareImage; }; var isTriangleImage= function(x) { return x instanceof TriangleImage; }; var isWedgeImage = function(x) { return x instanceof WedgeImage; }; var isEllipseImage = function(x) { return x instanceof EllipseImage; }; - var isLineImage = function(x) { return x instanceof LineImage; }; + var isLineImage = function(x) { return x instanceof LineImage; }; var isOverlayImage = function(x) { return x instanceof OverlayImage; }; - var isRotateImage = function(x) { return x instanceof RotateImage; }; - var isScaleImage = function(x) { return x instanceof ScaleImage; }; - var isCropImage = function(x) { return x instanceof CropImage; }; - var isFrameImage = function(x) { return x instanceof FrameImage; }; - var isPinholeImage = function(x) { return x instanceof PinholeImage; }; - var isFlipImage = function(x) { return x instanceof FlipImage; }; - var isTextImage = function(x) { return x instanceof TextImage; }; - var isFileImage = function(x) { return x instanceof FileImage; }; - var isFileVideo = function(x) { return x instanceof FileVideo; }; + var isRotateImage = function(x) { return x instanceof RotateImage; }; + var isScaleImage = function(x) { return x instanceof ScaleImage; }; + var isCropImage = function(x) { return x instanceof CropImage; }; + var isFrameImage = function(x) { return x instanceof FrameImage; }; + var isPinholeImage = function(x) { return x instanceof PinholeImage; }; + var isFlipImage = function(x) { return x instanceof FlipImage; }; + var isTextImage = function(x) { return x instanceof TextImage; }; + var isFileImage = function(x) { return x instanceof FileImage; }; + var isFileVideo = function(x) { return x instanceof FileVideo; }; /////////////////////////////////////////////////////////////// // Exports @@ -2035,8 +1989,6 @@ colorAtPosition: colorAtPosition, imageToColorList: imageToColorList, - verticalSymmetry: verticalSymmetry, - horizontalSymmetry: horizontalSymmetry, colorListToImage: colorListToImage, trimCanvas: trimCanvas, trimImageToCanvas: trimImageToCanvas, diff --git a/src/js/trove/internal-image-typed.js b/src/js/trove/internal-image-typed.js index 87ab42e78..84226d468 100644 --- a/src/js/trove/internal-image-typed.js +++ b/src/js/trove/internal-image-typed.js @@ -154,8 +154,6 @@ "image-width": ["arrow", ["Image"], "Number"], "image-height": ["arrow", ["Image"], "Number"], "image-baseline": ["arrow", ["Image"], "Number"], - "vertical-symmetry": ["arrow", ["Image"], "Number"], - "horizontal-symmetry": ["arrow", ["Image"], "Number"], "image-pinhole-x": ["arrow", ["Image"], "Number"], "image-pinhole-y": ["arrow", ["Image"], "Number"], "name-to-color": ["arrow", ["String"], "OptColor"], @@ -225,7 +223,7 @@ } return { x: jsnums.toFixnum(gf(val, "x")), y: jsnums.toFixnum(gf(val, "y")) }; }; - + const ANNOTS = { annString: runtime.String, annNumber: runtime.Number, @@ -266,7 +264,7 @@ "ff-symbol": function(_) { return "symbol"; }, "ff-system": function(_) { return "system"; }, }); - }, + }, annFontStyle: image.annFontStyle, unwrapFontStyle: function(fs) { return runtime.ffi.cases(pyAlwaysTrue, "FontStyle", fs, { @@ -360,4 +358,4 @@ "Scene": runtime.makePrimitiveAnn("Scene", checkScenePred) }); } -}) \ No newline at end of file +}) diff --git a/src/js/trove/internal-image-untyped.js b/src/js/trove/internal-image-untyped.js index 0bd9c7dcc..8103ca5c9 100644 --- a/src/js/trove/internal-image-untyped.js +++ b/src/js/trove/internal-image-untyped.js @@ -143,8 +143,6 @@ "color-list-to-bitmap": ["arrow", ["LoC", "Number", "Number"], "Image"], "image-width": ["arrow", ["Image"], "Number"], "image-height": ["arrow", ["Image"], "Number"], - "vertical-symmetry": ["arrow", ["Image"], "Number"], - "horizontal-symmetry": ["arrow", ["Image"], "Number"], "image-baseline": ["arrow", ["Image"], "Number"], "image-pinhole-x": ["arrow", ["Image"], "Number"], "image-pinhole-y": ["arrow", ["Image"], "Number"], @@ -196,7 +194,7 @@ }; var isPlaceY = function(x) { return (isString(x) && - (x.toString().toLowerCase() == "top" || + (x.toString().toLowerCase() == "top" || x.toString().toLowerCase() == "bottom" || x.toString().toLowerCase() == "baseline" || x.toString().toLowerCase() == "center" || @@ -286,7 +284,7 @@ x.toString().toLowerCase() == "modern" || x.toString().toLowerCase() == "symbol" || x.toString().toLowerCase() == "system")) - || (x === false); // false is also acceptable + || (x === false); // false is also acceptable }), unwrapFontFamily: identity, annFontStyle: ann("Font Style (\"normal\", \"italic\", or \"slant\")", function(x){ @@ -294,7 +292,7 @@ (x.toString().toLowerCase() == "normal" || x.toString().toLowerCase() == "italic" || x.toString().toLowerCase() == "slant")) - || (x === false); // false is also acceptable + || (x === false); // false is also acceptable }), unwrapFontStyle: identity, annFontWeight: ann("Font Weight", function(x){ @@ -302,7 +300,7 @@ (x.toString().toLowerCase() == "normal" || x.toString().toLowerCase() == "bold" || x.toString().toLowerCase() == "light")) - || (x === false); // false is also acceptable + || (x === false); // false is also acceptable }), unwrapFontWeight: identity, annPlaceX: ann("X Place (\"left\", \"middle\", \"center\", \"pinhole\", or \"right\")", isPlaceX), @@ -369,10 +367,10 @@ }); - + return runtime.makeModuleReturn(values, { "Image": image.Image, "Scene": runtime.makePrimitiveAnn("Scene", checkScenePred) }); } -}) \ No newline at end of file +}) diff --git a/src/js/trove/make-image.js b/src/js/trove/make-image.js index 957637d0c..3ed32b12e 100644 --- a/src/js/trove/make-image.js +++ b/src/js/trove/make-image.js @@ -1273,20 +1273,6 @@ return runtime.wrap(img.getHeight()); }); - f("vertical-symmetry", function(maybeImg) { - checkArity(1, arguments, "vertical-symmetry", false); - c1("vertical-symmetry", maybeImg, annImage); - var img = unwrapImage(maybeImg); - return runtime.wrap(image.verticalSymmetry(img)); - }); - - f("horizontal-symmetry", function(maybeImg) { - checkArity(1, arguments, "horizontal-symmetry", false); - c1("horizontal-symmetry", maybeImg, annImage); - var img = unwrapImage(maybeImg); - return runtime.wrap(image.horizontalSymmetry(img)); - }); - f("image-baseline", function(maybeImg) { checkArity(1, arguments, "image-baseline", false); c1("image-baseline", maybeImg, annImage); diff --git a/tests/pyret/tests/test-images.arr b/tests/pyret/tests/test-images.arr index b53ad2ec4..63c1d8b84 100644 --- a/tests/pyret/tests/test-images.arr +++ b/tests/pyret/tests/test-images.arr @@ -253,50 +253,3 @@ logo2 = image-file("pyret-logo-copy.png") check: logo is logo2 end - - -check "Symmetry": - fun less-than(n1, n2): n1 < n2 end - - # A simple square should be perfectly symmetrical in both directions - perfect-sym = square(50, mode-solid, blue) - vertical-symmetry(perfect-sym) is%(within-abs(0.01)) 1 - horizontal-symmetry(perfect-sym) is%(within-abs(0.01)) 1 - - # Two different-colored shapes beside each other break vertical symmetry (left vs right) - # but maintain horizontal symmetry (top vs bottom) - asym-vert = beside(square(20, mode-solid, red), square(20, mode-solid, blue)) - vertical-symmetry(asym-vert) is%(less-than) 1 - horizontal-symmetry(asym-vert) is%(within-abs(0.01)) 1 - - # Two different-colored shapes above each other break horizontal symmetry (top vs bottom) - # but maintain vertical symmetry (left vs right) - asym-horiz = above(square(20, mode-solid, red), square(20, mode-solid, blue)) - vertical-symmetry(asym-horiz) is%(within-abs(0.01)) 1 - horizontal-symmetry(asym-horiz) is%(less-than) 1 - - # A right triangle is typically asymmetrical in both dimensions - rt = right-triangle(40, 50, mode-solid, black) - (vertical-symmetry(rt) < 1) is%(within-abs(0.01)) true - (horizontal-symmetry(rt) < 1) is%(within-abs(0.01)) true - - # Empty scenes should be perfectly symmetrical - empty-sn = empty-scene(50, 50) - vertical-symmetry(empty-sn) is%(within-abs(0.01)) 1 - horizontal-symmetry(empty-sn) is%(within-abs(0.01)) 1 - - # Truly empty/0-width or 0-height images should also be symmetrical - empty-width = rectangle(0, 100, mode-solid, black) - vertical-symmetry(empty-width) is%(within-abs(0.01)) 1 - horizontal-symmetry(empty-width) is%(within-abs(0.01)) 1 - - # Create an asymmetrical image to test cropping - asym-base = beside(square(40, mode-solid, red), square(40, mode-solid, blue)) - vertical-symmetry(asym-base) is%(less-than) 1 - horizontal-symmetry(asym-base) is%(within-abs(0.01)) 1 - - # Cropping just the left side (the red square) restores vertical symmetry - cropped-sym = crop(0, 0, 40, 40, asym-base) - vertical-symmetry(cropped-sym) is%(within-abs(0.01)) 1 - horizontal-symmetry(cropped-sym) is%(within-abs(0.01)) 1 -end \ No newline at end of file From a33f7c7efa1b691354706ceb1432bdb7fecc151a Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Fri, 27 Mar 2026 10:46:52 -0500 Subject: [PATCH 4/9] re-do the changes without all the whitespace bullshit --- src/js/trove/image-lib.js | 49 ++++++++++++++++++++++++++ src/js/trove/internal-image-typed.js | 2 ++ src/js/trove/internal-image-untyped.js | 2 ++ src/js/trove/make-image.js | 14 ++++++++ 4 files changed, 67 insertions(+) diff --git a/src/js/trove/image-lib.js b/src/js/trove/image-lib.js index f5677f135..0b6bea3b7 100644 --- a/src/js/trove/image-lib.js +++ b/src/js/trove/image-lib.js @@ -1759,6 +1759,52 @@ LineImage.prototype = heir(BaseImage.prototype); + var verticalSymmetry = function(img) { + var width = img.getWidth(); + var height = img.getHeight(); + var halfW = Math.floor(width / 2); + if (halfW === 0 || height === 0) { return 1; } + var canvas = makeCanvas(width, height); + img.render(canvas.getContext("2d")); + var data = canvas.getContext("2d").getImageData(0, 0, width, height).data; + var sumSq = 0, count = 0; + for (var y = 0; y < height; y++) { + for (var x = 0; x < halfW; x++) { + var i1 = (y * width + x) * 4; + var i2 = (y * width + (width - 1 - x)) * 4; + for (var ch = 0; ch < 4; ch++) { + var d = data[i1 + ch] - data[i2 + ch]; + sumSq += d * d; + } + count += 4; + } + } + return 1 - Math.sqrt(sumSq / count) / 255; + }; + + var horizontalSymmetry = function(img) { + var width = img.getWidth(); + var height = img.getHeight(); + var halfH = Math.floor(height / 2); + if (width === 0 || halfH === 0) { return 1; } + var canvas = makeCanvas(width, height); + img.render(canvas.getContext("2d")); + var data = canvas.getContext("2d").getImageData(0, 0, width, height).data; + var sumSq = 0, count = 0; + for (var y = 0; y < halfH; y++) { + for (var x = 0; x < width; x++) { + var i1 = (y * width + x) * 4; + var i2 = ((height - 1 - y) * width + x) * 4; + for (var ch = 0; ch < 4; ch++) { + var d = data[i1 + ch] - data[i2 + ch]; + sumSq += d * d; + } + count += 4; + } + } + return 1 - Math.sqrt(sumSq / count) / 255; + }; + var colorAtPosition = function(img, x, y) { var width = img.getWidth(), height = img.getHeight(), @@ -2038,6 +2084,9 @@ colorBlue: colorBlue, colorAlpha: colorAlpha, colorString: colorString, + + verticalSymmetry: verticalSymmetry, + horizontalSymmetry: horizontalSymmetry, } ); return RUNTIME.makeJSModuleReturn(); diff --git a/src/js/trove/internal-image-typed.js b/src/js/trove/internal-image-typed.js index 84226d468..d8913f546 100644 --- a/src/js/trove/internal-image-typed.js +++ b/src/js/trove/internal-image-typed.js @@ -156,6 +156,8 @@ "image-baseline": ["arrow", ["Image"], "Number"], "image-pinhole-x": ["arrow", ["Image"], "Number"], "image-pinhole-y": ["arrow", ["Image"], "Number"], + "vertical-symmetry": ["arrow", ["Image"], "Number"], + "horizontal-symmetry": ["arrow", ["Image"], "Number"], "name-to-color": ["arrow", ["String"], "OptColor"], "color-named": ["arrow", ["String"], "Color"], "empty-image": "Image" diff --git a/src/js/trove/internal-image-untyped.js b/src/js/trove/internal-image-untyped.js index 8103ca5c9..3ccabe227 100644 --- a/src/js/trove/internal-image-untyped.js +++ b/src/js/trove/internal-image-untyped.js @@ -146,6 +146,8 @@ "image-baseline": ["arrow", ["Image"], "Number"], "image-pinhole-x": ["arrow", ["Image"], "Number"], "image-pinhole-y": ["arrow", ["Image"], "Number"], + "vertical-symmetry": ["arrow", ["Image"], "Number"], + "horizontal-symmetry": ["arrow", ["Image"], "Number"], "name-to-color": ["arrow", ["String"], "OptColor"], "color-named": ["arrow", ["String"], "Color"], "empty-image": "Image" diff --git a/src/js/trove/make-image.js b/src/js/trove/make-image.js index 3ed32b12e..957637d0c 100644 --- a/src/js/trove/make-image.js +++ b/src/js/trove/make-image.js @@ -1273,6 +1273,20 @@ return runtime.wrap(img.getHeight()); }); + f("vertical-symmetry", function(maybeImg) { + checkArity(1, arguments, "vertical-symmetry", false); + c1("vertical-symmetry", maybeImg, annImage); + var img = unwrapImage(maybeImg); + return runtime.wrap(image.verticalSymmetry(img)); + }); + + f("horizontal-symmetry", function(maybeImg) { + checkArity(1, arguments, "horizontal-symmetry", false); + c1("horizontal-symmetry", maybeImg, annImage); + var img = unwrapImage(maybeImg); + return runtime.wrap(image.horizontalSymmetry(img)); + }); + f("image-baseline", function(maybeImg) { checkArity(1, arguments, "image-baseline", false); c1("image-baseline", maybeImg, annImage); From df7657cb8e1e1cb801e20f27d77ad1b54b8686fa Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Fri, 27 Mar 2026 13:09:04 -0500 Subject: [PATCH 5/9] rename primitives --- src/js/trove/internal-image-typed.js | 4 +-- src/js/trove/internal-image-untyped.js | 4 +-- src/js/trove/make-image.js | 12 +++---- tests/pyret/tests/test-images.arr | 47 ++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/js/trove/internal-image-typed.js b/src/js/trove/internal-image-typed.js index d8913f546..8c8dcf707 100644 --- a/src/js/trove/internal-image-typed.js +++ b/src/js/trove/internal-image-typed.js @@ -156,8 +156,8 @@ "image-baseline": ["arrow", ["Image"], "Number"], "image-pinhole-x": ["arrow", ["Image"], "Number"], "image-pinhole-y": ["arrow", ["Image"], "Number"], - "vertical-symmetry": ["arrow", ["Image"], "Number"], - "horizontal-symmetry": ["arrow", ["Image"], "Number"], + "image-vertical-symmetry": ["arrow", ["Image"], "Number"], + "image-horizontal-symmetry": ["arrow", ["Image"], "Number"], "name-to-color": ["arrow", ["String"], "OptColor"], "color-named": ["arrow", ["String"], "Color"], "empty-image": "Image" diff --git a/src/js/trove/internal-image-untyped.js b/src/js/trove/internal-image-untyped.js index 3ccabe227..27e1cd4dd 100644 --- a/src/js/trove/internal-image-untyped.js +++ b/src/js/trove/internal-image-untyped.js @@ -146,8 +146,8 @@ "image-baseline": ["arrow", ["Image"], "Number"], "image-pinhole-x": ["arrow", ["Image"], "Number"], "image-pinhole-y": ["arrow", ["Image"], "Number"], - "vertical-symmetry": ["arrow", ["Image"], "Number"], - "horizontal-symmetry": ["arrow", ["Image"], "Number"], + "image-vertical-symmetry": ["arrow", ["Image"], "Number"], + "image-horizontal-symmetry": ["arrow", ["Image"], "Number"], "name-to-color": ["arrow", ["String"], "OptColor"], "color-named": ["arrow", ["String"], "Color"], "empty-image": "Image" diff --git a/src/js/trove/make-image.js b/src/js/trove/make-image.js index 957637d0c..e30a78504 100644 --- a/src/js/trove/make-image.js +++ b/src/js/trove/make-image.js @@ -1273,16 +1273,16 @@ return runtime.wrap(img.getHeight()); }); - f("vertical-symmetry", function(maybeImg) { - checkArity(1, arguments, "vertical-symmetry", false); - c1("vertical-symmetry", maybeImg, annImage); + f("image-vertical-symmetry", function(maybeImg) { + checkArity(1, arguments, "image-vertical-symmetry", false); + c1("image-vertical-symmetry", maybeImg, annImage); var img = unwrapImage(maybeImg); return runtime.wrap(image.verticalSymmetry(img)); }); - f("horizontal-symmetry", function(maybeImg) { - checkArity(1, arguments, "horizontal-symmetry", false); - c1("horizontal-symmetry", maybeImg, annImage); + f("image-horizontal-symmetry", function(maybeImg) { + checkArity(1, arguments, "image-horizontal-symmetry", false); + c1("image-horizontal-symmetry", maybeImg, annImage); var img = unwrapImage(maybeImg); return runtime.wrap(image.horizontalSymmetry(img)); }); diff --git a/tests/pyret/tests/test-images.arr b/tests/pyret/tests/test-images.arr index 63c1d8b84..934e0441e 100644 --- a/tests/pyret/tests/test-images.arr +++ b/tests/pyret/tests/test-images.arr @@ -253,3 +253,50 @@ logo2 = image-file("pyret-logo-copy.png") check: logo is logo2 end + +check "Symmetry": + fun less-than(n1, n2): n1 < n2 end + + # A simple square should be perfectly symmetrical in both directions + perfect-sym = square(50, mode-solid, blue) + vertical-symmetry(perfect-sym) is%(within-abs(0.01)) 1 + horizontal-symmetry(perfect-sym) is%(within-abs(0.01)) 1 + + # Two different-colored shapes beside each other break vertical symmetry (left vs right) + # but maintain horizontal symmetry (top vs bottom) + asym-vert = beside(square(20, mode-solid, red), square(20, mode-solid, blue)) + vertical-symmetry(asym-vert) is%(less-than) 1 + horizontal-symmetry(asym-vert) is%(within-abs(0.01)) 1 + + # Two different-colored shapes above each other break horizontal symmetry (top vs bottom) + # but maintain vertical symmetry (left vs right) + asym-horiz = above(square(20, mode-solid, red), square(20, mode-solid, blue)) + vertical-symmetry(asym-horiz) is%(within-abs(0.01)) 1 + horizontal-symmetry(asym-horiz) is%(less-than) 1 + + # A right triangle is typically asymmetrical in both dimensions + rt = right-triangle(40, 50, mode-solid, black) + (vertical-symmetry(rt) < 1) is%(within-abs(0.01)) true + (horizontal-symmetry(rt) < 1) is%(within-abs(0.01)) true + + # Empty scenes should be perfectly symmetrical + empty-sn = empty-scene(50, 50) + vertical-symmetry(empty-sn) is%(within-abs(0.01)) 1 + horizontal-symmetry(empty-sn) is%(within-abs(0.01)) 1 + + # Truly empty/0-width or 0-height images should also be symmetrical + empty-width = rectangle(0, 100, mode-solid, black) + vertical-symmetry(empty-width) is%(within-abs(0.01)) 1 + horizontal-symmetry(empty-width) is%(within-abs(0.01)) 1 + + # Create an asymmetrical image to test cropping + asym-base = beside(square(40, mode-solid, red), square(40, mode-solid, blue)) + vertical-symmetry(asym-base) is%(less-than) 1 + horizontal-symmetry(asym-base) is%(within-abs(0.01)) 1 + + # Cropping just the left side (the red square) restores vertical symmetry + cropped-sym = crop(0, 0, 40, 40, asym-base) + vertical-symmetry(cropped-sym) is%(within-abs(0.01)) 1 + horizontal-symmetry(cropped-sym) is%(within-abs(0.01)) 1 +end + From 912d8a679bed23c8977991f062b6b12426fc6677 Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Fri, 27 Mar 2026 13:20:33 -0500 Subject: [PATCH 6/9] fix function names for test suite --- tests/pyret/tests/test-images.arr | 32 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/pyret/tests/test-images.arr b/tests/pyret/tests/test-images.arr index 934e0441e..14321504f 100644 --- a/tests/pyret/tests/test-images.arr +++ b/tests/pyret/tests/test-images.arr @@ -259,44 +259,44 @@ check "Symmetry": # A simple square should be perfectly symmetrical in both directions perfect-sym = square(50, mode-solid, blue) - vertical-symmetry(perfect-sym) is%(within-abs(0.01)) 1 - horizontal-symmetry(perfect-sym) is%(within-abs(0.01)) 1 + image-vertical-symmetry(perfect-sym) is%(within-abs(0.01)) 1 + image-horizontal-symmetry(perfect-sym) is%(within-abs(0.01)) 1 # Two different-colored shapes beside each other break vertical symmetry (left vs right) # but maintain horizontal symmetry (top vs bottom) asym-vert = beside(square(20, mode-solid, red), square(20, mode-solid, blue)) - vertical-symmetry(asym-vert) is%(less-than) 1 - horizontal-symmetry(asym-vert) is%(within-abs(0.01)) 1 + image-vertical-symmetry(asym-vert) is%(less-than) 1 + image-horizontal-symmetry(asym-vert) is%(within-abs(0.01)) 1 # Two different-colored shapes above each other break horizontal symmetry (top vs bottom) # but maintain vertical symmetry (left vs right) asym-horiz = above(square(20, mode-solid, red), square(20, mode-solid, blue)) - vertical-symmetry(asym-horiz) is%(within-abs(0.01)) 1 - horizontal-symmetry(asym-horiz) is%(less-than) 1 + image-vertical-symmetry(asym-horiz) is%(within-abs(0.01)) 1 + image-horizontal-symmetry(asym-horiz) is%(less-than) 1 # A right triangle is typically asymmetrical in both dimensions rt = right-triangle(40, 50, mode-solid, black) - (vertical-symmetry(rt) < 1) is%(within-abs(0.01)) true - (horizontal-symmetry(rt) < 1) is%(within-abs(0.01)) true + (image-vertical-symmetry(rt) < 1) is%(within-abs(0.01)) true + (image-horizontal-symmetry(rt) < 1) is%(within-abs(0.01)) true # Empty scenes should be perfectly symmetrical empty-sn = empty-scene(50, 50) - vertical-symmetry(empty-sn) is%(within-abs(0.01)) 1 - horizontal-symmetry(empty-sn) is%(within-abs(0.01)) 1 + image-vertical-symmetry(empty-sn) is%(within-abs(0.01)) 1 + image-horizontal-symmetry(empty-sn) is%(within-abs(0.01)) 1 # Truly empty/0-width or 0-height images should also be symmetrical empty-width = rectangle(0, 100, mode-solid, black) - vertical-symmetry(empty-width) is%(within-abs(0.01)) 1 - horizontal-symmetry(empty-width) is%(within-abs(0.01)) 1 + image-vertical-symmetry(empty-width) is%(within-abs(0.01)) 1 + image-horizontal-symmetry(empty-width) is%(within-abs(0.01)) 1 # Create an asymmetrical image to test cropping asym-base = beside(square(40, mode-solid, red), square(40, mode-solid, blue)) - vertical-symmetry(asym-base) is%(less-than) 1 - horizontal-symmetry(asym-base) is%(within-abs(0.01)) 1 + image-vertical-symmetry(asym-base) is%(less-than) 1 + image-horizontal-symmetry(asym-base) is%(within-abs(0.01)) 1 # Cropping just the left side (the red square) restores vertical symmetry cropped-sym = crop(0, 0, 40, 40, asym-base) - vertical-symmetry(cropped-sym) is%(within-abs(0.01)) 1 - horizontal-symmetry(cropped-sym) is%(within-abs(0.01)) 1 + image-vertical-symmetry(cropped-sym) is%(within-abs(0.01)) 1 + image-horizontal-symmetry(cropped-sym) is%(within-abs(0.01)) 1 end From a8e058dfff1acc6e7b15033aa00ee7500db41c3d Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Fri, 27 Mar 2026 15:29:37 -0500 Subject: [PATCH 7/9] integrate improvements from @blerner --- src/js/trove/image-lib.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/js/trove/image-lib.js b/src/js/trove/image-lib.js index 0b6bea3b7..c999436ed 100644 --- a/src/js/trove/image-lib.js +++ b/src/js/trove/image-lib.js @@ -1768,10 +1768,12 @@ img.render(canvas.getContext("2d")); var data = canvas.getContext("2d").getImageData(0, 0, width, height).data; var sumSq = 0, count = 0; + + const basePixelIndex = (x, y) => ((y * width) + x) * 4; for (var y = 0; y < height; y++) { for (var x = 0; x < halfW; x++) { - var i1 = (y * width + x) * 4; - var i2 = (y * width + (width - 1 - x)) * 4; + var i1 = basePixelIndex(x, y); + var i2 = basePixelIndex(width - 1 - x, y); for (var ch = 0; ch < 4; ch++) { var d = data[i1 + ch] - data[i2 + ch]; sumSq += d * d; @@ -1791,10 +1793,11 @@ img.render(canvas.getContext("2d")); var data = canvas.getContext("2d").getImageData(0, 0, width, height).data; var sumSq = 0, count = 0; + const basePixelIndex = (x, y) => ((y * width) + x) * 4; for (var y = 0; y < halfH; y++) { for (var x = 0; x < width; x++) { - var i1 = (y * width + x) * 4; - var i2 = ((height - 1 - y) * width + x) * 4; + var i1 = basePixelIndex(x, y); + var i2 = basePixelIndex(x, height - 1 - y); for (var ch = 0; ch < 4; ch++) { var d = data[i1 + ch] - data[i2 + ch]; sumSq += d * d; From 616f18c3d02272b212e182bd16a1fc3613988849 Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Sat, 28 Mar 2026 08:36:12 -0500 Subject: [PATCH 8/9] attempting to implement @blerner s suggestion --- src/arr/trove/internal-image-shared.arr | 2 ++ src/js/trove/image-lib.js | 10 ++++++-- src/js/trove/internal-image-typed.js | 6 +++++ src/js/trove/internal-image-untyped.js | 6 +++++ src/js/trove/make-image.js | 32 +++++++++++++++--------- tests/pyret/tests/test-images.arr | 33 +++++++++++++------------ 6 files changed, 59 insertions(+), 30 deletions(-) diff --git a/src/arr/trove/internal-image-shared.arr b/src/arr/trove/internal-image-shared.arr index f27effa16..5a5ec5046 100644 --- a/src/arr/trove/internal-image-shared.arr +++ b/src/arr/trove/internal-image-shared.arr @@ -35,3 +35,5 @@ data FontFamily: end data FontStyle: fs-normal | fs-italic | fs-slant end data FontWeight: fw-normal | fw-bold | fw-light end + +data ImageSimilarity: ism-rmse(fudge-factor :: Number) end diff --git a/src/js/trove/image-lib.js b/src/js/trove/image-lib.js index c999436ed..8e2cb4f99 100644 --- a/src/js/trove/image-lib.js +++ b/src/js/trove/image-lib.js @@ -1759,7 +1759,13 @@ LineImage.prototype = heir(BaseImage.prototype); - var verticalSymmetry = function(img) { + // Note(Emmanuel): As of fall2026 the similarity arg is unused. + // We use RMSE across all pixel channels and assume an axis + // that is straight down the middle. + // But in the future, we could add other forms of similarity + // or allow for a "fudge factor" that considers an axis that + // isn't *quite* at the middle (imagefluency.com/reference/img_symmetry.html) + var verticalSymmetry = function(img, similarity) { var width = img.getWidth(); var height = img.getHeight(); var halfW = Math.floor(width / 2); @@ -1784,7 +1790,7 @@ return 1 - Math.sqrt(sumSq / count) / 255; }; - var horizontalSymmetry = function(img) { + var horizontalSymmetry = function(img, similarity) { var width = img.getWidth(); var height = img.getHeight(); var halfH = Math.floor(height / 2); diff --git a/src/js/trove/internal-image-typed.js b/src/js/trove/internal-image-typed.js index 8c8dcf707..79d57dfcc 100644 --- a/src/js/trove/internal-image-typed.js +++ b/src/js/trove/internal-image-typed.js @@ -283,6 +283,12 @@ "fw-light": function(_) { return "light"; }, }); }, + annImageSimilarity: image.ImageSimilarity, + unwrapImageSimilarity: function(ism){ + return runtime.ffi.cases(pyAlwaysTrue, "ImageSimilarity", ism, { + "ism-rmse": function(_) { return "ism-rmse"; }, + }); + }, annPlaceX: image.annXPlace, unwrapPlaceX: function(px) { return runtime.ffi.cases(pyAlwaysTrue, "XPlace", px, { diff --git a/src/js/trove/internal-image-untyped.js b/src/js/trove/internal-image-untyped.js index 27e1cd4dd..5dd818804 100644 --- a/src/js/trove/internal-image-untyped.js +++ b/src/js/trove/internal-image-untyped.js @@ -305,6 +305,12 @@ || (x === false); // false is also acceptable }), unwrapFontWeight: identity, + annImageSimilarity: ann("Image Similarity", function(x){ + return (isString(x) && + (x.toString().toLowerCase() == "ism-rmse" || + false)) // add other similarity measure here + }), + unwrapImageSimilarity: identity, annPlaceX: ann("X Place (\"left\", \"middle\", \"center\", \"pinhole\", or \"right\")", isPlaceX), unwrapPlaceX: function(val) { if (val.toString().toLowerCase() == "center") return "middle"; diff --git a/src/js/trove/make-image.js b/src/js/trove/make-image.js index e30a78504..ab31bb6c4 100644 --- a/src/js/trove/make-image.js +++ b/src/js/trove/make-image.js @@ -41,6 +41,8 @@ const unwrapFontStyle = annots.unwrapFontStyle; const annFontWeight = annots.annFontWeight; const unwrapFontWeight = annots.unwrapFontWeight; + const annImageSimilarity = annots.annImageSimilarity; + const unwrapImageSimilarity = annots.unwrapImageSimilarity; const annPlaceX = annots.annPlaceX; const unwrapPlaceX = annots.unwrapPlaceX; const annPlaceY = annots.annPlaceY; @@ -1273,18 +1275,24 @@ return runtime.wrap(img.getHeight()); }); - f("image-vertical-symmetry", function(maybeImg) { - checkArity(1, arguments, "image-vertical-symmetry", false); - c1("image-vertical-symmetry", maybeImg, annImage); - var img = unwrapImage(maybeImg); - return runtime.wrap(image.verticalSymmetry(img)); - }); - - f("image-horizontal-symmetry", function(maybeImg) { - checkArity(1, arguments, "image-horizontal-symmetry", false); - c1("image-horizontal-symmetry", maybeImg, annImage); - var img = unwrapImage(maybeImg); - return runtime.wrap(image.horizontalSymmetry(img)); + f("image-vertical-symmetry", function(maybeImg, maybeSimilarityMeasure) { + checkArity(2, arguments, "image-vertical-symmetry", false); + c2("image-vertical-symmetry", + maybeImg, annImage, + maybeSimilarityMeasure, annImageSimilarity); + var img = unwrapImage(maybeImg); + var similarity = unwrapImageSimilarity(annImageSimilarity); + return runtime.wrap(image.verticalSymmetry(img, similarity)); + }); + + f("image-horizontal-symmetry", function(maybeImg, maybeSimilarityMeasure) { + checkArity(2, arguments, "image-horizontal-symmetry", false); + c2("image-horizontal-symmetry", + maybeImg, annImage, + maybeSimilarityMeasure, annImageSimilarity); + var img = unwrapImage(maybeImg); + var similarity = unwrapImageSimilarity(annImageSimilarity); + return runtime.wrap(image.horizontalSymmetry(img, similarity)); }); f("image-baseline", function(maybeImg) { diff --git a/tests/pyret/tests/test-images.arr b/tests/pyret/tests/test-images.arr index 14321504f..88157f90b 100644 --- a/tests/pyret/tests/test-images.arr +++ b/tests/pyret/tests/test-images.arr @@ -257,46 +257,47 @@ end check "Symmetry": fun less-than(n1, n2): n1 < n2 end + # A simple square should be perfectly symmetrical in both directions perfect-sym = square(50, mode-solid, blue) - image-vertical-symmetry(perfect-sym) is%(within-abs(0.01)) 1 - image-horizontal-symmetry(perfect-sym) is%(within-abs(0.01)) 1 + image-vertical-symmetry(perfect-sym, ism-rmse(0)) is%(within-abs(0.01)) 1 + image-horizontal-symmetry(perfect-sym, ism-rmse(0)) is%(within-abs(0.01)) 1 # Two different-colored shapes beside each other break vertical symmetry (left vs right) # but maintain horizontal symmetry (top vs bottom) asym-vert = beside(square(20, mode-solid, red), square(20, mode-solid, blue)) - image-vertical-symmetry(asym-vert) is%(less-than) 1 - image-horizontal-symmetry(asym-vert) is%(within-abs(0.01)) 1 + image-vertical-symmetry(asym-vert, ism-rmse(0)) is%(less-than) 1 + image-horizontal-symmetry(asym-vert, ism-rmse(0)) is%(within-abs(0.01)) 1 # Two different-colored shapes above each other break horizontal symmetry (top vs bottom) # but maintain vertical symmetry (left vs right) asym-horiz = above(square(20, mode-solid, red), square(20, mode-solid, blue)) - image-vertical-symmetry(asym-horiz) is%(within-abs(0.01)) 1 - image-horizontal-symmetry(asym-horiz) is%(less-than) 1 + image-vertical-symmetry(asym-horiz, ism-rmse(0)) is%(within-abs(0.01)) 1 + image-horizontal-symmetry(asym-horiz, ism-rmse(0)) is%(less-than) 1 # A right triangle is typically asymmetrical in both dimensions rt = right-triangle(40, 50, mode-solid, black) - (image-vertical-symmetry(rt) < 1) is%(within-abs(0.01)) true - (image-horizontal-symmetry(rt) < 1) is%(within-abs(0.01)) true + (image-vertical-symmetry(rt, ism-rmse(0)) < 1) is%(within-abs(0.01)) true + (image-horizontal-symmetry(rt, ism-rmse(0)) < 1) is%(within-abs(0.01)) true # Empty scenes should be perfectly symmetrical empty-sn = empty-scene(50, 50) - image-vertical-symmetry(empty-sn) is%(within-abs(0.01)) 1 - image-horizontal-symmetry(empty-sn) is%(within-abs(0.01)) 1 + image-vertical-symmetry(empty-sn, ism-rmse(0)) is%(within-abs(0.01)) 1 + image-horizontal-symmetry(empty-sn, ism-rmse(0)) is%(within-abs(0.01)) 1 # Truly empty/0-width or 0-height images should also be symmetrical empty-width = rectangle(0, 100, mode-solid, black) - image-vertical-symmetry(empty-width) is%(within-abs(0.01)) 1 - image-horizontal-symmetry(empty-width) is%(within-abs(0.01)) 1 + image-vertical-symmetry(empty-width, ism-rmse(0)) is%(within-abs(0.01)) 1 + image-horizontal-symmetry(empty-width, ism-rmse(0)) is%(within-abs(0.01)) 1 # Create an asymmetrical image to test cropping asym-base = beside(square(40, mode-solid, red), square(40, mode-solid, blue)) - image-vertical-symmetry(asym-base) is%(less-than) 1 - image-horizontal-symmetry(asym-base) is%(within-abs(0.01)) 1 + image-vertical-symmetry(asym-base, ism-rmse(0)) is%(less-than) 1 + image-horizontal-symmetry(asym-base, ism-rmse(0)) is%(within-abs(0.01)) 1 # Cropping just the left side (the red square) restores vertical symmetry cropped-sym = crop(0, 0, 40, 40, asym-base) - image-vertical-symmetry(cropped-sym) is%(within-abs(0.01)) 1 - image-horizontal-symmetry(cropped-sym) is%(within-abs(0.01)) 1 + image-vertical-symmetry(cropped-sym, ism-rmse(0)) is%(within-abs(0.01)) 1 + image-horizontal-symmetry(cropped-sym, ism-rmse(0)) is%(within-abs(0.01)) 1 end From 65d19a835fac78f59fc4639e2da4d26c40fc04e5 Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Sat, 28 Mar 2026 09:22:55 -0500 Subject: [PATCH 9/9] Emmanuel learns how the annotation system works --- src/js/trove/image-lib.js | 3 +++ src/js/trove/internal-image-typed.js | 9 ++++++--- src/js/trove/internal-image-untyped.js | 7 +++++-- src/js/trove/make-image.js | 4 ++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/js/trove/image-lib.js b/src/js/trove/image-lib.js index 8e2cb4f99..c86520753 100644 --- a/src/js/trove/image-lib.js +++ b/src/js/trove/image-lib.js @@ -23,6 +23,7 @@ var isPoint = function(p) { return unwrap(rawIsPoint.app(p)); }; var unwrap = RUNTIME.unwrap; + var hasOwnProperty = {}.hasOwnProperty; @@ -58,6 +59,7 @@ var annFontFamily = imageTypes["FontFamily"]; var annFontStyle = imageTypes["FontStyle"]; var annFontWeight = imageTypes["FontWeight"]; + var annImageSimilarity = imageTypes["ImageSimilarity"]; // Color database var ColorDb = function() { @@ -2084,6 +2086,7 @@ isTextImage: isTextImage, isFileImage: isFileImage, isFileVideo: isFileVideo, + annImageSimilarity: annImageSimilarity, makeColor: makeColor, isColor: isColor, diff --git a/src/js/trove/internal-image-typed.js b/src/js/trove/internal-image-typed.js index 79d57dfcc..b083eafe8 100644 --- a/src/js/trove/internal-image-typed.js +++ b/src/js/trove/internal-image-typed.js @@ -62,6 +62,9 @@ [{ tag: "name", origin: { "import-type": "uri", uri: "builtin://internal-image-shared" }, name: "Point" }]], + "ImageSimilarity": { tag: "name", + origin: { "import-type": "uri", uri: "builtin://internal-image-shared" }, + name: "ImageSimilarity" }, }, values: { "circle": ["arrow", ["Number", "FillMode", "Color"], "Image"], @@ -156,8 +159,8 @@ "image-baseline": ["arrow", ["Image"], "Number"], "image-pinhole-x": ["arrow", ["Image"], "Number"], "image-pinhole-y": ["arrow", ["Image"], "Number"], - "image-vertical-symmetry": ["arrow", ["Image"], "Number"], - "image-horizontal-symmetry": ["arrow", ["Image"], "Number"], + "image-vertical-symmetry": ["arrow", ["Image", "ImageSimilarity"], "Number"], + "image-horizontal-symmetry": ["arrow", ["Image", "ImageSimilarity"], "Number"], "name-to-color": ["arrow", ["String"], "OptColor"], "color-named": ["arrow", ["String"], "Color"], "empty-image": "Image" @@ -283,7 +286,7 @@ "fw-light": function(_) { return "light"; }, }); }, - annImageSimilarity: image.ImageSimilarity, + annImageSimilarity: image.annImageSimilarity, unwrapImageSimilarity: function(ism){ return runtime.ffi.cases(pyAlwaysTrue, "ImageSimilarity", ism, { "ism-rmse": function(_) { return "ism-rmse"; }, diff --git a/src/js/trove/internal-image-untyped.js b/src/js/trove/internal-image-untyped.js index 5dd818804..4e71a33f8 100644 --- a/src/js/trove/internal-image-untyped.js +++ b/src/js/trove/internal-image-untyped.js @@ -48,6 +48,9 @@ [{ tag: "name", origin: { "import-type": "uri", uri: "builtin://internal-image-shared" }, name: "Point" }]], + "ImageSimilarity": { tag: "name", + origin: { "import-type": "uri", uri: "builtin://internal-image-shared" }, + name: "ImageSimilarity" }, }, values: { "circle": ["arrow", ["Number", "FillMode", "ColorString"], "Image"], @@ -146,8 +149,8 @@ "image-baseline": ["arrow", ["Image"], "Number"], "image-pinhole-x": ["arrow", ["Image"], "Number"], "image-pinhole-y": ["arrow", ["Image"], "Number"], - "image-vertical-symmetry": ["arrow", ["Image"], "Number"], - "image-horizontal-symmetry": ["arrow", ["Image"], "Number"], + "image-vertical-symmetry": ["arrow", ["Image", "ImageSimilarity"], "Number"], + "image-horizontal-symmetry": ["arrow", ["Image", "ImageSimilarity"], "Number"], "name-to-color": ["arrow", ["String"], "OptColor"], "color-named": ["arrow", ["String"], "Color"], "empty-image": "Image" diff --git a/src/js/trove/make-image.js b/src/js/trove/make-image.js index ab31bb6c4..f72ad12e5 100644 --- a/src/js/trove/make-image.js +++ b/src/js/trove/make-image.js @@ -1281,7 +1281,7 @@ maybeImg, annImage, maybeSimilarityMeasure, annImageSimilarity); var img = unwrapImage(maybeImg); - var similarity = unwrapImageSimilarity(annImageSimilarity); + var similarity = unwrapImageSimilarity(maybeSimilarityMeasure); return runtime.wrap(image.verticalSymmetry(img, similarity)); }); @@ -1291,7 +1291,7 @@ maybeImg, annImage, maybeSimilarityMeasure, annImageSimilarity); var img = unwrapImage(maybeImg); - var similarity = unwrapImageSimilarity(annImageSimilarity); + var similarity = unwrapImageSimilarity(maybeSimilarityMeasure); return runtime.wrap(image.horizontalSymmetry(img, similarity)); });