-
Notifications
You must be signed in to change notification settings - Fork 0
Fix recognition correctness regressions #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
262 changes: 217 additions & 45 deletions
262
src/main/java/com/maxello1/whamagic/magic/RingDetector.java
Large diffs are not rendered by default.
Oops, something went wrong.
27 changes: 27 additions & 0 deletions
27
src/main/java/com/maxello1/whamagic/magic/SpellStackUpdater.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package com.maxello1.whamagic.magic; | ||
|
|
||
| import com.maxello1.whamagic.parser.Point; | ||
| import com.maxello1.whamagic.parser.SpellParser; | ||
| import net.minecraft.core.component.DataComponentType; | ||
| import net.minecraft.world.item.ItemStack; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| /** Applies a server parse result and its source strokes to spell paper data. */ | ||
| public final class SpellStackUpdater { | ||
| private SpellStackUpdater() {} | ||
|
|
||
| public static void applyParseResultToStack( | ||
| ItemStack stack, | ||
| SpellParser.ParseResult result, | ||
| List<List<Point>> strokes, | ||
| DataComponentType<StoredSpell> storedSpellComponent, | ||
| DataComponentType<List<List<Point>>> strokesComponent) { | ||
| stack.set(strokesComponent, strokes); | ||
| if (result.isValidSpell()) { | ||
| stack.set(storedSpellComponent, StoredSpell.fromIr(result.ir, strokes)); | ||
| } else { | ||
| stack.remove(storedSpellComponent); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,12 @@ | |
| /** Weight of turning angle in $P+ distance calculation. */ | ||
| private static final double ANGLE_WEIGHT = 0.15; | ||
|
|
||
| /** Closed templates above this value are meaningfully asymmetric. */ | ||
| private static final double ASYMMETRIC_TEMPLATE_MIN = 0.06; | ||
|
|
||
| /** Reject a closed candidate when it is far more symmetric than its template. */ | ||
| private static final double MIN_ASYMMETRY_RATIO = 0.45; | ||
|
|
||
| // ---- Data Structures ---- | ||
|
|
||
| /** | ||
|
|
@@ -81,13 +87,17 @@ | |
| public final com.maxello1.whamagic.magic.SymbolKind kind; | ||
| public final String element; | ||
| public final CloudPoint[] points; // always length N after normalization | ||
| public final int strokeCount; // original number of strokes in the template | ||
| public final boolean closedSingleStroke; | ||
| public final double centralSymmetryError; | ||
| public final com.maxello1.whamagic.magic.SigilSemantic sigilSemantic; | ||
| public final com.maxello1.whamagic.magic.SignSemantic signSemantic; | ||
| public final com.maxello1.whamagic.magic.SymbolRecognitionRules recognitionRules; | ||
|
|
||
| public PointCloudTemplate(String id, String displayName, | ||
| com.maxello1.whamagic.magic.SymbolKind kind, String element, | ||
| CloudPoint[] points, | ||
| CloudPoint[] points, int strokeCount, boolean closedSingleStroke, | ||
| double centralSymmetryError, | ||
| com.maxello1.whamagic.magic.SigilSemantic sigilSemantic, | ||
| com.maxello1.whamagic.magic.SignSemantic signSemantic, | ||
| com.maxello1.whamagic.magic.SymbolRecognitionRules recognitionRules) { | ||
|
|
@@ -96,6 +106,9 @@ | |
| this.kind = kind; | ||
| this.element = element; | ||
| this.points = points; | ||
| this.strokeCount = strokeCount; | ||
| this.closedSingleStroke = closedSingleStroke; | ||
| this.centralSymmetryError = centralSymmetryError; | ||
| this.sigilSemantic = sigilSemantic; | ||
| this.signSemantic = signSemantic; | ||
| this.recognitionRules = recognitionRules; | ||
|
|
@@ -130,7 +143,7 @@ | |
| private static final List<PointCloudTemplate> templates = new ArrayList<>(); | ||
|
|
||
| /** @deprecated Use instance methods via SymbolRecognizer interface instead. */ | ||
| public static void clearTemplatesStatic() { | ||
|
Check warning on line 146 in src/main/java/com/maxello1/whamagic/parser/PointCloudRecognizer.java
|
||
| templates.clear(); | ||
| } | ||
|
|
||
|
|
@@ -146,7 +159,10 @@ | |
| com.maxello1.whamagic.magic.SymbolRecognitionRules recognitionRules) { | ||
| CloudPoint[] cloud = strokesToCloud(strokes); | ||
| CloudPoint[] normalized = normalize(cloud, N); | ||
| templates.add(new PointCloudTemplate(id, displayName, kind, element, normalized, | ||
| boolean closedSingleStroke = isClosedSingleStroke(strokes); | ||
| double symmetryError = closedSingleStroke ? centralSymmetryError(normalized) : 0.0; | ||
| templates.add(new PointCloudTemplate(id, displayName, kind, element, normalized, strokes.size(), | ||
| closedSingleStroke, symmetryError, | ||
| sigilSemantic, signSemantic, recognitionRules)); | ||
| LOGGER.debug("Registered $P template '{}' ({} strokes -> {} points)", id, strokes.size(), N); | ||
| } | ||
|
|
@@ -179,13 +195,38 @@ | |
| null, null, com.maxello1.whamagic.magic.RecognitionRejectionReason.NO_STROKES); | ||
| } | ||
| CloudPoint[] candidate = normalize(candidateCloud, N); | ||
| boolean candidateClosedSingleStroke = isClosedSingleStroke(strokes); | ||
| double candidateSymmetryError = candidateClosedSingleStroke ? centralSymmetryError(candidate) : 0.0; | ||
|
|
||
| // Match against each template of the expected kind | ||
| List<TemplateScore> scored = new ArrayList<>(); | ||
| for (PointCloudTemplate tmpl : templates) { | ||
| if (tmpl.kind != expectedKind) continue; | ||
| double distance = greedyCloudMatch(candidate, tmpl.points, N); | ||
| double score = Math.max((2.0 - distance) / 2.0, 0.0); | ||
| if (candidateClosedSingleStroke && tmpl.closedSingleStroke | ||
| && tmpl.centralSymmetryError >= ASYMMETRIC_TEMPLATE_MIN | ||
| && candidateSymmetryError < tmpl.centralSymmetryError * MIN_ASYMMETRY_RATIO) { | ||
| score = 0.0; | ||
| } | ||
| // Stroke-count completeness check: | ||
| // 1. Missing strokes: reject strictly. Prevents incomplete symbols (like Light missing a square) from matching. | ||
| // 2. Too many extra strokes: reject if more than double. Prevents complex drawings from matching simple 1-stroke shapes. | ||
| // 3. Moderate extra strokes: allow with mild penalty. This is CRITICAL for multi-symbol spells where | ||
| // CandidateGenerator might group multiple symbols into a single super-candidate (e.g. 7 wind + 2 column = 9 strokes). | ||
| int candidateStrokeCount = strokes.size(); | ||
| int templateStrokeCount = tmpl.strokeCount; | ||
| if (candidateStrokeCount < templateStrokeCount) { | ||
| // Missing strokes: strictly reject | ||
| score = 0; | ||
| } else if (candidateStrokeCount > templateStrokeCount * 2) { | ||
| // More than double the strokes: reject (e.g. 5-stroke candidate vs 1-stroke template) | ||
| score = 0; | ||
| } else if (candidateStrokeCount > templateStrokeCount) { | ||
| // Moderate extra strokes: mild proportional penalty | ||
| double ratio = (double) templateStrokeCount / candidateStrokeCount; | ||
| score *= Math.pow(ratio, 0.5); | ||
| } | ||
| scored.add(new TemplateScore(tmpl, score, distance)); | ||
| } | ||
|
|
||
|
|
@@ -617,6 +658,61 @@ | |
|
|
||
| // ---- Utilities ---- | ||
|
|
||
| private static boolean isClosedSingleStroke(List<List<Point>> strokes) { | ||
| if (strokes.size() != 1 || strokes.get(0).size() < 4) return false; | ||
| List<Point> stroke = strokes.get(0); | ||
| Point first = stroke.get(0); | ||
| Point last = stroke.get(stroke.size() - 1); | ||
|
|
||
| double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE; | ||
| double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE; | ||
| for (Point point : stroke) { | ||
| minX = Math.min(minX, point.x); | ||
| minY = Math.min(minY, point.y); | ||
| maxX = Math.max(maxX, point.x); | ||
| maxY = Math.max(maxY, point.y); | ||
| } | ||
| double diagonal = Math.hypot(maxX - minX, maxY - minY); | ||
| double closureGap = Math.hypot(first.x - last.x, first.y - last.y); | ||
| return diagonal > 1e-10 && closureGap <= diagonal * 0.08; | ||
| } | ||
|
|
||
| /** | ||
| * Mean distance from each point's reflection through the centroid to the | ||
| * nearest cloud point, normalized by the cloud diagonal. | ||
| */ | ||
| private static double centralSymmetryError(CloudPoint[] points) { | ||
| if (points.length == 0) return 0.0; | ||
| double cx = 0, cy = 0; | ||
| double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE; | ||
| double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE; | ||
| for (CloudPoint point : points) { | ||
| cx += point.x(); | ||
| cy += point.y(); | ||
| minX = Math.min(minX, point.x()); | ||
| minY = Math.min(minY, point.y()); | ||
| maxX = Math.max(maxX, point.x()); | ||
| maxY = Math.max(maxY, point.y()); | ||
| } | ||
| cx /= points.length; | ||
| cy /= points.length; | ||
| double diagonal = Math.hypot(maxX - minX, maxY - minY); | ||
| if (diagonal < 1e-10) return 0.0; | ||
|
|
||
| double error = 0.0; | ||
| for (CloudPoint point : points) { | ||
| double reflectedX = 2.0 * cx - point.x(); | ||
| double reflectedY = 2.0 * cy - point.y(); | ||
| double nearest = Double.MAX_VALUE; | ||
| for (CloudPoint other : points) { | ||
| nearest = Math.min(nearest, | ||
| Math.hypot(other.x() - reflectedX, other.y() - reflectedY)); | ||
| } | ||
| error += nearest; | ||
| } | ||
| return error / points.length / diagonal; | ||
| } | ||
|
|
||
| /** | ||
| * $P+ distance: combines spatial distance with angular difference. | ||
| * Spatial distance measures position similarity. | ||
|
|
||
79 changes: 79 additions & 0 deletions
79
src/test/java/com/maxello1/whamagic/SpellStackUpdateTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package com.maxello1.whamagic; | ||
|
|
||
| import com.google.gson.Gson; | ||
| import com.google.gson.JsonElement; | ||
| import com.google.gson.JsonObject; | ||
| import com.maxello1.whamagic.parser.Point; | ||
| import com.maxello1.whamagic.parser.SpellDictionary; | ||
| import com.maxello1.whamagic.parser.SpellParser; | ||
| import net.minecraft.SharedConstants; | ||
| import net.minecraft.server.Bootstrap; | ||
| import net.minecraft.world.item.ItemStack; | ||
| import org.junit.jupiter.api.BeforeAll; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.io.File; | ||
| import java.io.FileReader; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.*; | ||
|
|
||
| class SpellStackUpdateTest { | ||
| private static final Gson GSON = new Gson(); | ||
| private static net.minecraft.core.component.DataComponentType<com.maxello1.whamagic.magic.StoredSpell> storedSpellComponent; | ||
| private static net.minecraft.core.component.DataComponentType<List<List<Point>>> rawStrokesComponent; | ||
|
|
||
| @BeforeAll | ||
| static void loadDictionary() { | ||
| SharedConstants.tryDetectVersion(); | ||
| Bootstrap.bootStrap(); | ||
| net.minecraft.world.item.Items.PAPER.builtInRegistryHolder() | ||
| .bindComponents(net.minecraft.core.component.DataComponentMap.EMPTY); | ||
| storedSpellComponent = new net.minecraft.core.component.DataComponentType.Builder<com.maxello1.whamagic.magic.StoredSpell>() | ||
| .persistent(com.maxello1.whamagic.magic.StoredSpell.CODEC).build(); | ||
| rawStrokesComponent = new net.minecraft.core.component.DataComponentType.Builder<List<List<Point>>>() | ||
| .persistent(Point.STROKES_CODEC).build(); | ||
| SpellDictionary.ensureLoaded(); | ||
| } | ||
|
|
||
| @Test | ||
| void invalidOverwriteRemovesStoredSpellAndKeepsLatestRawStrokes() throws Exception { | ||
| List<List<Point>> validStrokes = loadStrokes( | ||
| "src/test/resources/fixtures/canonical/multi/spell_light_complete.json"); | ||
| List<List<Point>> invalidStrokes = loadStrokes( | ||
| "src/test/resources/fixtures/canonical/negative/neg_light_incomplete.json"); | ||
|
|
||
| SpellParser.ParseResult validResult = SpellParser.parse(validStrokes); | ||
| SpellParser.ParseResult invalidResult = SpellParser.parse(invalidStrokes); | ||
| assertTrue(validResult.isValidSpell(), "Complete Light fixture must produce a valid spell"); | ||
| assertFalse(invalidResult.isValidSpell(), "Incomplete Light fixture must remain invalid"); | ||
|
|
||
| ItemStack stack = new ItemStack(net.minecraft.world.item.Items.PAPER); | ||
| com.maxello1.whamagic.magic.SpellStackUpdater.applyParseResultToStack( | ||
| stack, validResult, validStrokes, storedSpellComponent, rawStrokesComponent); | ||
| assertNotNull(stack.get(storedSpellComponent)); | ||
| assertSame(validStrokes, stack.get(rawStrokesComponent)); | ||
|
|
||
| com.maxello1.whamagic.magic.SpellStackUpdater.applyParseResultToStack( | ||
| stack, invalidResult, invalidStrokes, storedSpellComponent, rawStrokesComponent); | ||
| assertNull(stack.get(storedSpellComponent), | ||
| "Invalid overwrite must remove the previous compiled spell"); | ||
| assertSame(invalidStrokes, stack.get(rawStrokesComponent), | ||
| "Raw strokes must reflect the latest submitted drawing"); | ||
| } | ||
|
|
||
| private static List<List<Point>> loadStrokes(String path) throws Exception { | ||
| JsonObject fixture = GSON.fromJson(new FileReader(new File(path)), JsonObject.class); | ||
| List<List<Point>> strokes = new ArrayList<>(); | ||
| for (JsonElement strokeElement : fixture.getAsJsonArray("strokes")) { | ||
| List<Point> stroke = new ArrayList<>(); | ||
| for (JsonElement pointElement : strokeElement.getAsJsonArray()) { | ||
| JsonObject point = pointElement.getAsJsonObject(); | ||
| stroke.add(new Point(point.get("x").getAsDouble(), point.get("y").getAsDouble())); | ||
| } | ||
| strokes.add(stroke); | ||
| } | ||
| return strokes; | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.