Skip to content
Open
2 changes: 2 additions & 0 deletions src/arr/trove/internal-image-shared.arr
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/arr/trove/starter2024.arr
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ provide from I:
underlay-align-list,
underlay-list,
underlay-xy,
vertical-symmetry,
horizontal-symmetry,
wedge,
x-center,
x-left,
Expand Down
61 changes: 61 additions & 0 deletions src/js/trove/image-lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
var isPoint = function(p) { return unwrap(rawIsPoint.app(p)); };
var unwrap = RUNTIME.unwrap;


var hasOwnProperty = {}.hasOwnProperty;


Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -1759,6 +1761,61 @@

LineImage.prototype = heir(BaseImage.prototype);

// 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);
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;

const basePixelIndex = (x, y) => ((y * width) + x) * 4;
for (var y = 0; y < height; y++) {
for (var x = 0; x < halfW; x++) {
var i1 = basePixelIndex(x, y);
var i2 = basePixelIndex(width - 1 - x, y);
for (var ch = 0; ch < 4; ch++) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the actual similarity formula being used in these two functions? I think, if I'm understanding correctly, that it's the root mean-squared error over every pixel and every color channel? Probably worth a clarifying comment somewhere...

var d = data[i1 + ch] - data[i2 + ch];
sumSq += d * d;
}
count += 4;
}
}
return 1 - Math.sqrt(sumSq / count) / 255;
};

var horizontalSymmetry = function(img, similarity) {
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;
const basePixelIndex = (x, y) => ((y * width) + x) * 4;
for (var y = 0; y < halfH; y++) {
for (var x = 0; x < width; x++) {
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;
}
count += 4;
}
}
return 1 - Math.sqrt(sumSq / count) / 255;
};

var colorAtPosition = function(img, x, y) {
var width = img.getWidth(),
height = img.getHeight(),
Expand Down Expand Up @@ -2029,6 +2086,7 @@
isTextImage: isTextImage,
isFileImage: isFileImage,
isFileVideo: isFileVideo,
annImageSimilarity: annImageSimilarity,

makeColor: makeColor,
isColor: isColor,
Expand All @@ -2038,6 +2096,9 @@
colorBlue: colorBlue,
colorAlpha: colorAlpha,
colorString: colorString,

verticalSymmetry: verticalSymmetry,
horizontalSymmetry: horizontalSymmetry,
}
);
return RUNTIME.makeJSModuleReturn();
Expand Down
11 changes: 11 additions & 0 deletions src/js/trove/internal-image-typed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -156,6 +159,8 @@
"image-baseline": ["arrow", ["Image"], "Number"],
"image-pinhole-x": ["arrow", ["Image"], "Number"],
"image-pinhole-y": ["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"
Expand Down Expand Up @@ -281,6 +286,12 @@
"fw-light": function(_) { return "light"; },
});
},
annImageSimilarity: image.annImageSimilarity,
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, {
Expand Down
11 changes: 11 additions & 0 deletions src/js/trove/internal-image-untyped.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -146,6 +149,8 @@
"image-baseline": ["arrow", ["Image"], "Number"],
"image-pinhole-x": ["arrow", ["Image"], "Number"],
"image-pinhole-y": ["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"
Expand Down Expand Up @@ -303,6 +308,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";
Expand Down
22 changes: 22 additions & 0 deletions src/js/trove/make-image.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1273,6 +1275,26 @@
return runtime.wrap(img.getHeight());
});

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(maybeSimilarityMeasure);
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(maybeSimilarityMeasure);
return runtime.wrap(image.horizontalSymmetry(img, similarity));
});

f("image-baseline", function(maybeImg) {
checkArity(1, arguments, "image-baseline", false);
c1("image-baseline", maybeImg, annImage);
Expand Down
48 changes: 48 additions & 0 deletions tests/pyret/tests/test-images.arr
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,51 @@ 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)
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, 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, 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, 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, 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, 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, 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, ism-rmse(0)) is%(within-abs(0.01)) 1
image-horizontal-symmetry(cropped-sym, ism-rmse(0)) is%(within-abs(0.01)) 1
end

Loading