diff --git a/core/tests/conftest.py b/core/tests/conftest.py
new file mode 100644
index 0000000..2519076
--- /dev/null
+++ b/core/tests/conftest.py
@@ -0,0 +1,14 @@
+"""Pytest configuration shared by every test module under ``tests/``.
+
+Lives next to the test files so pytest picks it up regardless of
+whether the suite is invoked from ``core/``, ``core/ic_core/``, or
+the repo root.
+"""
+from __future__ import annotations
+
+
+def pytest_configure(config):
+ config.addinivalue_line(
+ "markers",
+ "slow: marks tests that retrain the classifier across folds — skipped by default",
+ )
diff --git a/core/tests/fixtures/Hufnagel-example.csv b/core/tests/fixtures/Hufnagel-example.csv
new file mode 100644
index 0000000..7c838c2
--- /dev/null
+++ b/core/tests/fixtures/Hufnagel-example.csv
@@ -0,0 +1,99 @@
+filename,file_size,file_attributes,region_count,region_id,region_shape_attributes,region_attributes
+Antiphonal_12v_hfngl.jpg,131544,{},99,0,"{""name"":""rect"",""x"":196,""y"":122,""width"":27,""height"":42}","{""type"":""f-clef""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,1,"{""name"":""rect"",""x"":195,""y"":230,""width"":26,""height"":50}","{""type"":""f-clef""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,2,"{""name"":""rect"",""x"":196,""y"":351,""width"":26,""height"":46}","{""type"":""f-clef""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,3,"{""name"":""rect"",""x"":192,""y"":485,""width"":31,""height"":47}","{""type"":""f-clef""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,4,"{""name"":""rect"",""x"":199,""y"":608,""width"":27,""height"":49}","{""type"":""f-clef""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,5,"{""name"":""rect"",""x"":199,""y"":735,""width"":26,""height"":43}","{""type"":""f-clef""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,6,"{""name"":""rect"",""x"":665,""y"":722,""width"":39,""height"":35}","{""type"":""custos""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,7,"{""name"":""rect"",""x"":654,""y"":601,""width"":42,""height"":30}","{""type"":""custos""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,8,"{""name"":""rect"",""x"":650,""y"":474,""width"":46,""height"":35}","{""type"":""custos""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,9,"{""name"":""rect"",""x"":646,""y"":337,""width"":38,""height"":34}","{""type"":""custos""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,10,"{""name"":""rect"",""x"":645,""y"":214,""width"":45,""height"":33}","{""type"":""custos""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,11,"{""name"":""rect"",""x"":639,""y"":108,""width"":41,""height"":31}","{""type"":""custos""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,12,"{""name"":""rect"",""x"":221,""y"":125,""width"":23,""height"":47}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,13,"{""name"":""rect"",""x"":245,""y"":112,""width"":23,""height"":46}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,14,"{""name"":""rect"",""x"":268,""y"":102,""width"":22,""height"":43}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,15,"{""name"":""rect"",""x"":297,""y"":111,""width"":30,""height"":47}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,16,"{""name"":""rect"",""x"":341,""y"":122,""width"":35,""height"":41}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,17,"{""name"":""rect"",""x"":382,""y"":122,""width"":30,""height"":31}","{""type"":""puncta""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,18,"{""name"":""rect"",""x"":410,""y"":125,""width"":35,""height"":20}","{""type"":""puncta""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,19,"{""name"":""rect"",""x"":439,""y"":123,""width"":31,""height"":40}","{""type"":""pes""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,20,"{""name"":""rect"",""x"":470,""y"":110,""width"":19,""height"":45}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,21,"{""name"":""rect"",""x"":491,""y"":126,""width"":28,""height"":33}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,22,"{""name"":""rect"",""x"":517,""y"":121,""width"":40,""height"":37}","{""type"":""torculus""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,23,"{""name"":""rect"",""x"":561,""y"":138,""width"":22,""height"":42}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,24,"{""name"":""rect"",""x"":601,""y"":138,""width"":35,""height"":41}","{""type"":""pes""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,25,"{""name"":""rect"",""x"":222,""y"":228,""width"":42,""height"":45}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,26,"{""name"":""rect"",""x"":274,""y"":239,""width"":28,""height"":19}","{""type"":""puncta""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,27,"{""name"":""rect"",""x"":319,""y"":237,""width"":19,""height"":42}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,28,"{""name"":""rect"",""x"":341,""y"":237,""width"":30,""height"":35}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,29,"{""name"":""rect"",""x"":375,""y"":227,""width"":20,""height"":44}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,30,"{""name"":""rect"",""x"":395,""y"":217,""width"":29,""height"":47}","{""type"":""pes""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,31,"{""name"":""rect"",""x"":419,""y"":227,""width"":45,""height"":30}","{""type"":""torculus""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,32,"{""name"":""rect"",""x"":467,""y"":234,""width"":23,""height"":38}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,33,"{""name"":""rect"",""x"":485,""y"":224,""width"":12,""height"":47}","{""type"":""divisio""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,34,"{""name"":""rect"",""x"":496,""y"":216,""width"":38,""height"":44}","{""type"":""scandicus""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,35,"{""name"":""rect"",""x"":525,""y"":207,""width"":26,""height"":47}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,36,"{""name"":""rect"",""x"":549,""y"":216,""width"":21,""height"":18}","{""type"":""puncta""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,37,"{""name"":""rect"",""x"":575,""y"":217,""width"":20,""height"":19}","{""type"":""puncta""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,38,"{""name"":""rect"",""x"":613,""y"":216,""width"":23,""height"":45}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,39,"{""name"":""rect"",""x"":224,""y"":349,""width"":24,""height"":43}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,40,"{""name"":""rect"",""x"":271,""y"":341,""width"":33,""height"":42}","{""type"":""pes""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,41,"{""name"":""rect"",""x"":309,""y"":348,""width"":18,""height"":44}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,42,"{""name"":""rect"",""x"":337,""y"":356,""width"":21,""height"":21}","{""type"":""puncta""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,43,"{""name"":""rect"",""x"":353,""y"":354,""width"":8,""height"":40}","{""type"":""divisio""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,44,"{""name"":""rect"",""x"":355,""y"":360,""width"":33,""height"":36}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,45,"{""name"":""rect"",""x"":389,""y"":347,""width"":19,""height"":45}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,46,"{""name"":""rect"",""x"":417,""y"":355,""width"":37,""height"":43}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,47,"{""name"":""rect"",""x"":449,""y"":359,""width"":41,""height"":37}","{""type"":""torculus""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,48,"{""name"":""rect"",""x"":476,""y"":388,""width"":14,""height"":33}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,49,"{""name"":""rect"",""x"":486,""y"":356,""width"":8,""height"":61}","{""type"":""divisio""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,50,"{""name"":""rect"",""x"":493,""y"":374,""width"":18,""height"":43}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,51,"{""name"":""rect"",""x"":525,""y"":385,""width"":25,""height"":21}","{""type"":""puncta""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,52,"{""name"":""rect"",""x"":557,""y"":377,""width"":23,""height"":38}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,53,"{""name"":""rect"",""x"":594,""y"":356,""width"":18,""height"":37}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,54,"{""name"":""rect"",""x"":623,""y"":354,""width"":22,""height"":41}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,55,"{""name"":""rect"",""x"":219,""y"":495,""width"":28,""height"":34}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,56,"{""name"":""rect"",""x"":249,""y"":511,""width"":32,""height"":38}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,57,"{""name"":""rect"",""x"":289,""y"":488,""width"":18,""height"":44}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,58,"{""name"":""rect"",""x"":311,""y"":478,""width"":42,""height"":30}","{""type"":""torculus""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,59,"{""name"":""rect"",""x"":347,""y"":484,""width"":12,""height"":41}","{""type"":""divisio""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,60,"{""name"":""rect"",""x"":359,""y"":489,""width"":20,""height"":36}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,61,"{""name"":""rect"",""x"":386,""y"":471,""width"":25,""height"":39}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,62,"{""name"":""rect"",""x"":428,""y"":479,""width"":22,""height"":20}","{""type"":""puncta""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,63,"{""name"":""rect"",""x"":464,""y"":467,""width"":33,""height"":42}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,64,"{""name"":""rect"",""x"":512,""y"":490,""width"":30,""height"":40}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,65,"{""name"":""rect"",""x"":553,""y"":476,""width"":18,""height"":43}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,66,"{""name"":""rect"",""x"":574,""y"":470,""width"":31,""height"":44}","{""type"":""pes""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,67,"{""name"":""rect"",""x"":602,""y"":480,""width"":53,""height"":29}","{""type"":""torculus""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,68,"{""name"":""rect"",""x"":631,""y"":611,""width"":18,""height"":38}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,69,"{""name"":""rect"",""x"":649,""y"":600,""width"":7,""height"":49}","{""type"":""divisio""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,70,"{""name"":""rect"",""x"":586,""y"":599,""width"":35,""height"":51}","{""type"":""pes""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,71,"{""name"":""rect"",""x"":547,""y"":610,""width"":40,""height"":44}","{""type"":""pes""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,72,"{""name"":""rect"",""x"":524,""y"":645,""width"":27,""height"":19}","{""type"":""puncta""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,73,"{""name"":""rect"",""x"":499,""y"":627,""width"":9,""height"":44}","{""type"":""divisio""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,75,"{""name"":""rect"",""x"":411,""y"":612,""width"":46,""height"":44}","{""type"":""torculus""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,76,"{""name"":""rect"",""x"":376,""y"":613,""width"":32,""height"":39}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,77,"{""name"":""rect"",""x"":352,""y"":600,""width"":21,""height"":42}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,78,"{""name"":""rect"",""x"":319,""y"":616,""width"":19,""height"":39}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,79,"{""name"":""rect"",""x"":279,""y"":626,""width"":18,""height"":40}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,80,"{""name"":""rect"",""x"":252,""y"":614,""width"":19,""height"":38}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,81,"{""name"":""rect"",""x"":222,""y"":619,""width"":22,""height"":34}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,82,"{""name"":""rect"",""x"":239,""y"":608,""width"":11,""height"":47}","{""type"":""divisio""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,83,"{""name"":""rect"",""x"":225,""y"":741,""width"":18,""height"":39}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,84,"{""name"":""rect"",""x"":245,""y"":745,""width"":28,""height"":35}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,85,"{""name"":""rect"",""x"":287,""y"":731,""width"":19,""height"":40}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,86,"{""name"":""rect"",""x"":306,""y"":721,""width"":37,""height"":27}","{""type"":""torculus""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,87,"{""name"":""rect"",""x"":345,""y"":743,""width"":21,""height"":37}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,88,"{""name"":""rect"",""x"":364,""y"":733,""width"":11,""height"":49}","{""type"":""divisio""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,89,"{""name"":""rect"",""x"":387,""y"":718,""width"":15,""height"":40}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,90,"{""name"":""rect"",""x"":420,""y"":730,""width"":23,""height"":21}","{""type"":""puncta""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,91,"{""name"":""rect"",""x"":455,""y"":721,""width"":19,""height"":44}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,92,"{""name"":""rect"",""x"":479,""y"":729,""width"":26,""height"":39}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,93,"{""name"":""rect"",""x"":506,""y"":739,""width"":31,""height"":39}","{""type"":""clivis""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,94,"{""name"":""rect"",""x"":540,""y"":732,""width"":26,""height"":40}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,95,"{""name"":""rect"",""x"":577,""y"":718,""width"":19,""height"":44}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,96,"{""name"":""rect"",""x"":598,""y"":727,""width"":42,""height"":43}","{""type"":""pes""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,97,"{""name"":""rect"",""x"":644,""y"":738,""width"":14,""height"":39}","{""type"":""virga""}"
+Antiphonal_12v_hfngl.jpg,131544,{},99,98,"{""name"":""rect"",""x"":657,""y"":727,""width"":7,""height"":47}","{""type"":""divisio""}"
\ No newline at end of file
diff --git a/core/tests/fixtures/Hufnagel-example_training_data.xml b/core/tests/fixtures/Hufnagel-example_training_data.xml
new file mode 100644
index 0000000..8cca1a5
--- /dev/null
+++ b/core/tests/fixtures/Hufnagel-example_training_data.xml
@@ -0,0 +1,593 @@
+
+
+
+
+
+
+
+ 175 7 19 10 16 11 15 10 16 11 16 9 18 6 21 6 21 6 1 8 12 14 13 12 15 12 15 11 16 6 21 6 21 6 21 5 22 5 22 5 22 6 21 5 22 5 22 5 21 5 23 3 24 2 25 2 2 1 21 2 25 1 26 1 180
+
+
+
+
+
+ 251 5 19 10 15 10 15 11 14 10 14 7 1 3 15 6 4 1 15 6 20 9 1 6 9 17 9 15 11 14 12 13 13 8 18 8 18 8 18 8 18 8 19 7 19 7 20 6 20 6 20 6 20 6 20 6 20 6 20 6 20 6 20 6 20 6 20 5 21 4 22 2 221
+
+
+
+
+
+ 224 3 20 8 17 12 13 12 13 12 13 12 14 5 3 3 15 5 21 6 2 3 1 3 11 14 12 12 14 11 15 10 16 5 21 5 21 5 21 5 21 5 21 5 21 5 21 5 21 5 21 5 21 4 22 4 22 4 22 4 22 3 23 3 23 1 25 1 198
+
+
+
+
+
+ 269 5 25 9 21 10 19 13 17 14 16 14 17 5 3 5 17 6 4 4 2 1 14 6 5 1 19 6 6 1 18 7 2 7 15 16 15 16 15 15 16 14 17 7 3 3 18 6 25 6 25 6 26 5 25 6 25 6 26 5 26 5 25 5 26 5 27 4 26 5 26 4 27 3 28 2 29 1 233
+
+
+
+
+
+ 204 1 24 6 20 8 18 12 14 12 14 12 14 12 15 6 3 2 16 6 21 7 20 16 11 15 12 14 13 13 14 12 15 7 2 2 16 7 20 6 21 6 22 5 21 6 22 5 21 6 21 6 21 6 21 6 21 6 21 6 21 5 23 3 24 2 25 2 10 6 8 3 3 2 2 2 1 3 1 2 243
+
+
+
+
+
+ 195 6 19 9 15 12 13 13 13 11 14 11 15 6 2 2 16 6 20 7 2 1 1 5 10 17 9 18 8 14 12 13 13 6 20 5 21 5 21 6 20 5 21 5 21 5 21 5 21 5 21 5 21 5 21 5 21 5 21 5 21 4 22 3 23 1 173
+
+
+
+
+
+ 787 3 35 5 34 5 34 5 34 5 34 5 35 3 341
+
+
+
+
+
+ 598 1 39 3 38 4 37 6 35 7 36 7 35 7 35 6 36 5 38 2 285
+
+
+
+
+
+ 975 2 42 4 41 6 39 8 36 8 40 5 404
+
+
+
+
+
+ 769 1 36 3 33 5 32 7 31 8 31 7 31 6 33 3 256
+
+
+
+
+
+ 688 1 42 3 41 2 1 1 39 2 2 2 38 2 1 2 2 1 43 3 38 2 2 2 40 3 41 3 438
+
+
+
+
+
+ 710 1 38 3 37 4 36 5 35 7 33 8 32 1 1 8 31 1 2 2 1 2 36 4 37 3 193
+
+
+
+
+
+ 148 3 19 4 18 5 18 7 15 10 12 13 11 11 12 10 13 10 13 9 14 6 17 6 16 6 17 6 17 6 17 7 17 6 17 6 17 6 16 7 17 6 17 5 18 6 14 12 13 7 17 5 18 5 18 5 18 5 17 5 18 5 18 4 19 3 20 2 22 1 153
+
+
+
+
+
+ 11 2 5 3 1 1 107 1 20 3 19 4 18 5 17 7 15 10 13 12 11 12 11 11 12 9 14 7 16 7 16 7 16 6 17 7 16 6 17 6 17 6 17 6 14 12 1 3 10 6 17 7 16 6 17 6 17 6 17 6 17 6 17 6 17 6 17 5 18 4 19 3 20 2 103 1 92
+
+
+
+
+
+ 98 2 18 4 17 5 17 7 13 12 10 12 5 22 5 10 13 8 14 7 15 7 15 7 15 7 15 7 15 7 15 7 15 7 15 7 15 7 16 6 16 6 16 6 16 6 16 6 16 6 16 6 16 6 16 6 16 6 15 6 16 6 17 2 20 1 146
+
+
+
+
+
+ 16 3 1 2 1 5 1 8 1 1 2 2 2 1 149 2 27 4 25 5 24 6 22 9 21 10 19 1 1 9 18 1 2 12 14 1 3 10 20 9 21 8 22 7 23 7 23 6 24 6 24 7 23 7 23 7 12 30 11 7 23 7 23 7 23 6 24 7 23 7 23 7 23 7 23 6 24 6 24 4 26 3 27 2 28 1 112 2 1 3 3 1 136
+
+
+
+
+
+ 230 3 31 4 30 6 28 8 26 11 3 1 19 15 19 18 3 7 6 2 1 12 20 1 3 10 25 8 27 7 28 6 29 6 29 7 28 7 28 7 28 6 29 6 29 7 28 7 28 6 30 6 29 6 28 7 15 2 2 3 2 15 5 1 18 6 29 6 29 5 30 6 29 6 29 5 30 2 34 2 85
+
+
+
+
+
+ 167 1 27 3 26 4 25 6 22 8 22 8 21 1 1 7 5 1 5 21 1 3 13 1 3 8 2 1 14 1 5 9 21 8 23 7 23 5 25 3 28 1 284 2 2 26 2 2 2 2 17 1 4
+
+
+
+
+
+ 87 2 32 3 31 4 29 6 28 8 27 8 25 1 2 8 4 3 1 1 3 2 13 8 27 8 2 1 24 9 27 8 27 7 13 1 14 6 13 2 15 3 14 3 31 4 32 3 29 1 3 2 34 1
+
+
+
+
+
+ 145 2 28 4 26 6 24 7 23 9 21 13 2 1 3 4 1 4 2 14 1 1 15 1 1 10 21 9 12 2 8 7 13 4 8 7 11 5 7 7 11 8 5 7 10 10 3 8 9 14 1 7 10 12 2 7 7 1 3 10 3 7 12 7 5 7 14 4 7 6 15 2 8 6 25 6 25 6 25 6 7 2 2 2 1 3 1 31 1 2 2 8 25 6 25 6 25 5 26 5 26 6 24 6 26 4 26 4 28 1 30 1 43
+
+
+
+
+
+ 85 2 16 3 15 5 13 7 11 9 9 12 7 11 8 10 9 9 11 7 12 7 12 7 12 7 12 6 13 6 13 6 13 6 13 6 13 7 7 2 2 9 11 6 13 6 13 6 13 7 12 7 12 7 12 7 12 7 12 7 12 7 12 7 12 7 12 7 12 7 12 7 12 7 6 20 5 3 17 1 18 1 31
+
+
+
+
+
+ 67 3 23 9 18 12 1 2 12 17 8 24 4 19 9 19 9 7 4 8 9 7 6 6 9 6 7 5 10 7 5 6 11 5 6 5 12 6 5 5 12 6 5 7 9 6 6 9 8 5 5 10 8 5 4 13 5 6 4 11 7 6 7 7 8 6 8 5 9 6 9 2 8 27 1 9 22 6 22 5 23 5 23 4 24 2 26 1 79
+
+
+
+
+
+ 145 1 38 3 36 5 34 8 3 2 25 15 25 15 24 16 22 22 1 3 2 2 3 3 4 18 23 6 5 6 23 6 5 6 23 6 5 6 23 6 5 6 22 7 5 5 23 7 5 5 24 7 4 6 19 1 3 7 4 7 17 1 4 7 3 9 15 1 5 7 3 11 12 1 6 7 3 9 3 1 9 2 6 7 3 8 13 2 7 6 6 6 13 3 7 5 6 4 14 9 2 5 8 1 13 19 1 3 1 2 1 1 1 27 3 1 19 16 23 16 28 11 33 5 183
+
+
+
+
+
+ 142 4 17 5 16 7 14 9 6 5 1 16 5 14 10 11 11 9 13 8 14 8 14 7 15 7 16 6 16 6 15 7 16 6 16 6 16 6 16 6 16 6 16 6 16 6 16 6 16 6 8 20 9 7 16 6 16 5 17 4 18 3 19 2 122
+
+
+
+
+
+ 129 1 32 3 31 4 29 6 29 6 28 7 28 9 3 2 7 3 10 13 22 12 24 10 15 1 10 8 15 3 8 7 15 6 7 7 14 7 7 7 14 8 6 7 13 10 5 7 13 14 1 7 14 11 4 6 15 9 4 7 16 7 5 7 17 5 6 7 28 7 28 7 28 7 28 7 28 7 12 2 14 7 28 6 29 6 29 6 29 6 29 6 29 5 30 3 32 2 33 1 86
+
+
+
+
+
+ 92 18 3 4 1 4 1 3 9 1 1 5 4 3 7 3 41 1 40 2 39 4 37 6 35 7 34 9 32 12 30 15 27 14 28 13 14 2 13 12 14 4 11 12 14 6 11 7 17 8 10 7 16 10 4 1 4 7 15 12 2 2 4 6 12 1 2 37 5 2 1 10 8 6 14 2 3 8 9 6 13 2 6 4 11 6 13 2 6 2 13 6 13 1 22 6 36 6 36 6 35 7 36 6 36 6 36 6 36 6 35 7 35 6 37 5 36 6 36 6 36 5 37 4 22 2 1 1 1 5 1 4 1 17 25 2 141
+
+
+
+
+
+ 68 1 26 3 23 5 22 7 20 8 19 9 18 1 1 9 6 1 5 17 1 10 8 8 4 1 15 8 3 1 17 9 19 8 20 6 23 5 23 4 24 2 44
+
+
+
+
+
+ 86 2 16 4 14 5 13 8 9 11 8 11 7 17 1 1 1 14 6 12 6 12 8 10 9 9 10 7 12 6 12 7 12 7 12 7 12 7 13 6 13 6 13 6 12 7 12 7 12 7 13 6 13 6 12 8 6 15 1 1 8 6 13 6 13 5 14 5 14 4 15 3 16 2 16 1 51
+
+
+
+
+
+ 187 5 7 1 16 8 4 3 14 16 13 18 8 24 1 5 3 19 11 19 11 6 4 8 12 6 5 7 12 6 6 5 13 6 5 6 13 6 6 6 12 7 4 8 11 6 5 9 10 6 4 10 2 1 7 6 4 11 9 6 6 8 10 6 7 6 11 6 7 4 13 6 8 2 14 6 24 6 24 6 24 5 24 7 1 3 1 3 1 2 1 2 2 6 1 5 2 1 23 1 86
+
+
+
+
+
+ 41 19 50 2 17 4 15 6 13 7 12 9 3 1 6 13 6 13 8 11 10 9 11 7 13 7 13 6 14 6 14 6 14 6 8 20 6 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 5 15 5 15 5 14 5 15 4 16 3 12 8 2 2 2 2 64
+
+
+
+
+
+ 164 3 25 4 24 5 24 5 23 6 23 8 19 15 3 8 1 17 8 1 6 10 11 2 6 9 11 4 5 8 10 6 5 7 10 8 4 7 10 8 4 6 10 10 2 7 11 10 1 7 12 8 3 6 12 7 4 6 13 5 4 7 14 3 5 7 23 6 22 7 22 7 22 7 22 11 4 21 22 7 7 1 14 7 7 1 14 7 22 7 22 6 23 6 23 6 23 6 23 5 24 4 25 3 26 1 130
+
+
+
+
+
+ 45 5 6 1 5 1 4 5 2 22 5 1 33 1 110 5 38 10 4 2 28 17 27 18 26 19 26 19 26 7 2 10 26 7 5 7 26 7 5 7 26 7 5 7 27 6 5 6 20 1 7 6 5 5 20 1 7 7 6 6 17 8 2 6 5 8 9 1 4 40 5 18 4 12 10 18 4 12 11 16 8 8 18 10 11 5 22 6 13 3 25 3 14 2 192
+
+
+
+
+
+ 149 3 18 5 17 6 16 8 14 11 2 1 8 15 3 20 10 9 14 8 15 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 17 6 17 6 17 6 17 6 16 7 16 7 16 7 17 5 17 5 19 2 21 1 37
+
+
+
+
+
+ 47 1 102 1 11 1 11 1 47 1 18 1 10 2 4 1 5 2 3 2 71 1 11 1 11 1 11 1 11 1 47 1 11 1 11 1 23 1 11 1 11 1 53
+
+
+
+
+
+ 181 3 34 4 33 6 31 8 29 11 27 13 25 16 2 4 1 28 25 11 27 9 19 3 7 7 20 5 6 7 18 8 6 6 18 9 4 7 17 11 3 7 17 12 2 7 18 11 2 7 19 8 4 7 21 6 4 7 21 5 5 7 13 1 8 3 7 6 12 3 17 6 11 5 16 6 10 7 15 6 9 10 12 7 8 36 2 12 11 6 5 1 5 9 13 5 4 2 6 7 14 5 14 4 14 6 15 2 15 6 32 5 33 3 35 2 36 2 201
+
+
+
+
+
+ 92 2 22 4 21 6 20 7 17 11 2 2 11 13 13 12 15 10 16 9 17 7 9 3 7 6 10 3 7 6 10 4 6 6 10 5 5 6 10 7 3 6 10 16 10 16 3 1 1 1 2 18 10 7 2 7 10 5 4 7 10 3 6 7 10 3 6 7 10 3 6 7 10 3 6 7 10 3 6 7 10 3 6 7 10 3 6 7 10 3 6 6 11 3 6 6 11 3 6 6 11 3 6 6 11 3 6 6 11 3 6 5 12 3 6 4 13 3 6 2 15 18 8 2 5 1 18 2 4 2 18 2 24 2 24 2 24 1 77
+
+
+
+
+
+ 114 2 18 3 17 5 14 7 13 8 6 1 2 15 1 5 5 8 13 10 11 8 14 6 15 5 16 3 19 1 13
+
+
+
+
+
+ 50 1 17 4 15 5 14 6 13 8 11 9 10 10 3 3 1 4 1 9 1 3 8 9 11 9 11 7 14 5 15 4 17 1 71
+
+
+
+
+
+ 105 2 19 4 18 5 17 6 16 8 15 9 14 11 12 12 11 12 11 10 13 9 14 8 15 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 6 17 6 17 6 17 6 17 6 17 6 17 6 17 6 17 6 17 5 18 4 19 2 21 2 151
+
+
+
+
+
+ 4 3 1 1 4 3 1 6 61 1 21 3 20 4 19 6 17 8 16 10 14 10 14 9 15 8 16 6 18 6 18 6 18 6 18 6 17 7 17 7 17 7 17 7 17 7 17 7 17 7 17 7 17 7 17 7 17 6 18 6 18 6 18 6 18 6 18 6 18 5 19 3 21 2 22 1 44 1 4 12 3 1 96
+
+
+
+
+
+ 57 3 29 4 27 6 26 8 24 10 23 11 21 13 2 6 2 1 3 3 2 14 21 10 23 9 13 2 9 6 15 3 9 6 13 6 7 7 12 7 7 7 11 9 6 7 10 11 6 6 10 11 5 7 10 1 1 10 4 7 13 8 5 7 14 5 7 6 16 3 8 6 16 1 10 6 27 6 27 6 27 6 9 1 5 1 11 9 24 6 27 6 27 5 28 5 28 5 27 6 27 5 28 4 29 3 30 2 31 1 147
+
+
+
+
+
+ 26 1 71 3 14 4 13 6 11 7 10 10 8 12 6 11 8 9 9 8 10 7 11 6 11 7 11 7 11 7 7 1 3 9 3 1 5 7 12 6 12 6 12 6 12 6 12 6 12 6 12 6 11 7 11 7 11 7 12 5 13 4 14 4 14 3 15 2 16 1 48 18 72
+
+
+
+
+
+ 116 2 18 4 16 5 14 7 13 8 12 1 1 7 11 2 1 8 4 2 7 7 15 6 15 7 14 6 15 5 16 4 17 2 53
+
+
+
+
+
+ 107 3 154 8 48
+
+
+
+
+
+ 80 3 28 6 25 10 3 1 18 15 18 16 9 3 4 25 7 18 15 6 3 9 15 6 5 6 16 6 5 6 16 6 5 6 16 6 5 6 16 6 5 5 17 6 5 5 17 6 5 5 16 7 5 6 16 6 4 9 14 6 4 10 12 7 4 11 11 7 3 11 13 6 6 7 13 7 7 5 15 6 8 2 17 5 28 5 21 33 6 6 27 6 27 5 28 3 30 2 124
+
+
+
+
+
+ 121 3 15 5 12 8 10 10 8 13 6 14 7 11 8 10 9 10 9 8 10 7 12 7 12 7 12 7 9 10 12 7 12 6 13 6 13 6 13 6 13 6 13 6 13 7 12 6 13 6 13 6 13 6 13 6 13 6 13 5 14 4 15 3 16 2 17 2 14 9 1 8 77
+
+
+
+
+
+ 272 5 31 9 5 2 20 17 19 18 18 20 8 37 8 7 5 9 16 7 7 6 17 7 7 6 17 7 7 6 17 7 7 5 18 7 7 5 18 7 7 6 17 7 6 8 16 7 6 9 4 1 10 7 6 13 11 6 7 10 14 6 9 7 15 6 9 5 17 6 10 3 18 6 31 6 31 6 31 5 23 1 8 7 4 1 2 2 4 3 3 16 2 6 1 1 8 1 1 4 7 5 20 5 8 4 20 5 7 4 20 2 3 1 7 2 35 1 214
+
+
+
+
+
+ 101 2 38 4 36 7 5 2 25 10 3 4 23 18 23 18 2 2 5 5 2 3 2 20 21 12 1 7 21 8 5 7 22 7 5 7 22 6 6 7 22 6 6 6 23 7 5 5 24 7 6 5 23 6 6 8 10 1 10 6 6 10 2 1 3 2 11 6 6 11 18 6 5 11 19 6 7 8 20 6 8 5 22 6 9 3 23 6 10 1 17 2 5 6 27 2 6 6 26 15 24 39 1 18 22 18 23 17 24 1 3 12 19 1 14 6 18 3 17 2 18 5 35 6 34 9 31 12
+
+
+
+
+
+ 36 1 11 3 10 5 8 6 7 9 4 12 2 12 2 11 4 9 5 8 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 4 1 1 9 1 16 3 7 7 7 7 7 7 6 8 6 8 6 8 4 10 2 12 2 37
+
+
+
+
+
+ 85 1 7 1 16 1 7 1 7 1 23 1 7 1 1 1 36 1 7 1 7 1 7 1 7 1 7 1 2 8 5 1 7 1 7 1 7 1 7 1 7 1 7 1 2 2 3 1 2 6 2 6 2 3 2 1 2 2 3 1 2 1 4 2 14 2 6 2 41 1 1 5 1 6 58
+
+
+
+
+
+ 116 1 15 4 13 6 12 7 9 10 2 1 5 12 3 15 4 12 7 10 9 8 10 7 11 7 11 7 11 7 11 7 11 7 11 6 12 6 12 6 12 6 12 7 11 7 11 7 11 7 11 7 11 7 11 7 11 7 10 8 11 10 2 2 4 7 11 6 12 5 13 3 15 2 16 1 32
+
+
+
+
+
+ 17 1 3 2 2 3 2 1 5 1 100 2 22 3 20 6 18 7 17 9 15 10 15 1 2 7 18 8 12 1 4 9 16 9 17 6 19 6 20 3 22 1 63
+
+
+
+
+
+ 60 1 20 3 19 4 18 5 17 6 16 8 7 3 1 19 8 12 11 11 12 9 14 9 14 8 15 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 7 16 6 17 6 17 5 15 1 2 4 2 2 5 1 9 2 128
+
+
+
+
+
+ 44 2 15 3 14 4 12 7 10 8 10 9 9 14 1 1 1 16 3 11 7 9 10 7 10 7 12 6 12 6 12 6 12 6 12 6 12 6 12 5 13 5 12 7 11 7 11 7 11 7 11 7 12 5 12 8 3 2 2 11 11 3 15 2 102
+
+
+
+
+
+ 100 3 18 4 16 6 15 8 14 8 13 9 13 11 4 1 4 14 11 10 12 9 13 8 14 7 15 7 15 7 15 7 15 7 15 7 16 6 15 7 15 7 15 7 15 7 15 7 15 7 15 6 16 6 11 12 15 4 18 3 19 2 166
+
+
+
+
+
+ 0 2 26 3 8 6 11 3 7 9 2 2 5 2 6 15 5 1 6 16 5 1 2 2 1 22 5 18 10 7 2 9 5 3 2 7 5 6 5 3 2 7 5 5 6 3 2 7 5 5 6 2 3 7 5 4 7 1 4 7 4 5 12 7 4 6 11 7 4 8 9 7 4 9 8 6 4 10 8 6 4 9 9 6 6 7 9 6 7 4 11 6 8 2 12 6 22 5 23 5 23 6 22 6 22 17 4 1 6 5 23 3 25 1 27 1 106
+
+
+
+
+
+ 139 2 28 7 5 2 16 11 1 4 15 17 14 18 9 1 2 30 2 20 12 7 3 10 12 7 6 6 13 7 7 5 13 7 7 4 14 7 7 4 14 7 6 6 13 7 6 8 11 7 6 9 11 6 5 11 9 7 5 10 10 7 7 7 11 7 8 5 12 6 9 3 15 6 25 7 26 6 26 6 26 6 26 6 20 53 1 4 12 3 29 2 154
+
+
+
+
+
+ 117 2 15 3 13 6 11 8 9 10 4 1 2 15 4 13 5 11 7 10 9 8 10 7 11 7 11 6 12 6 12 6 12 6 11 7 11 7 11 7 11 7 11 7 11 7 11 7 11 7 11 6 12 7 7 14 1 3 4 6 12 6 12 5 13 4 14 3 15 2 16 1 85
+
+
+
+
+
+ 0 14 3 2 14 4 5 42 64 1 40 3 38 7 5 1 28 9 2 3 27 15 26 16 25 17 26 16 26 6 4 6 26 6 4 6 22 1 2 7 4 6 21 1 4 6 4 6 20 1 4 7 4 5 20 1 5 7 4 6 17 2 7 6 3 8 15 7 2 7 4 8 13 17 3 9 2 2 8 18 2 13 4 42 4 18 1 3 1 10 1 1 1 2 10 10 9 6 21 4 11 3 220
+
+
+
+
+
+ 157 2 9 2 10 6 1 6 1 1 1 2 1 5 217 8 1 1 2 7 3 1 48
+
+
+
+
+
+ 70 1 18 2 17 3 15 5 14 7 12 9 11 13 3 40 5 11 9 10 10 7 13 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 14 6 9 2 1 12 1 6 1 17 5 3 17 2 53
+
+
+
+
+
+ 86 3 21 4 20 5 18 9 15 12 2 1 4 25 7 12 13 11 14 10 15 8 17 8 18 6 19 7 17 7 18 7 18 7 18 7 18 7 19 6 18 7 18 7 18 7 18 7 18 7 18 7 18 7 6 1 1 3 2 2 2 8 18 6 19 6 19 4 21 3 22 2 116
+
+
+
+
+
+ 0 22 98 3 17 5 16 6 14 9 14 8 4 2 9 7 3 1 11 10 12 8 14 6 17 4 18 1 101
+
+
+
+
+
+ 245 2 30 5 25 10 3 1 18 16 15 20 6 33 4 9 1 11 13 7 5 8 13 7 6 6 14 6 7 6 14 7 6 5 15 7 6 5 15 7 6 7 13 7 5 8 13 7 5 9 12 7 4 12 10 7 5 10 11 7 6 8 12 6 8 6 13 6 9 4 14 6 10 1 16 6 27 6 27 6 27 6 22 1 2 8 2 4 1 1 2 1 16 6 27 4 29 3 30 1 192
+
+
+
+
+
+ 131 1 27 7 5 1 15 15 14 17 11 21 8 22 8 20 10 7 4 8 11 7 6 6 11 7 6 5 12 7 6 5 12 6 7 4 13 6 7 5 12 6 6 8 10 6 6 8 10 6 5 10 9 6 5 11 8 6 7 8 9 6 8 6 10 6 8 5 11 6 9 2 13 6 9 2 13 6 24 6 24 6 12 1 2 6 1 29 2 7 24 5 25 4 26 3 27 2 27 2 146
+
+
+
+
+
+ 54 18 9 1 16 1 16 2 15 4 13 5 12 6 11 9 9 12 6 12 6 11 7 10 8 9 9 8 10 7 11 7 11 7 11 7 11 7 11 7 8 1 1 9 6 1 3 7 11 7 11 7 11 7 11 6 12 7 11 7 11 6 12 6 12 5 13 4 14 3 15 2 103 1 6 1 7 2 1
+
+
+
+
+
+ 142 3 27 6 24 7 23 9 22 10 6 32 1 2 1 1 1 23 16 12 19 11 11 1 8 9 11 4 7 8 11 6 6 7 11 7 6 6 12 8 5 6 10 10 4 7 11 12 1 7 13 9 2 7 13 8 3 7 14 6 5 6 14 5 6 6 16 1 7 7 24 7 25 6 25 6 24 7 10 2 10 9 2 1 2 2 2 1 14 7 9 1 14 6 25 6 25 6 25 6 25 6 25 5 26 4 27 3 28 3 138
+
+
+
+
+
+ 0 2 2 1 1 2 181 4 47 8 42 17 34 19 33 20 32 7 2 12 31 7 7 8 31 8 7 7 31 8 7 7 32 6 8 7 23 1 8 6 8 7 21 2 9 6 8 6 21 2 10 6 8 6 19 9 5 7 7 6 18 22 7 8 14 23 8 9 11 25 7 13 4 2 2 24 8 12 21 11 10 10 26 5 14 6 31 1 16 4 50 2 224
+
+
+
+
+
+ 63 2 15 4 13 6 11 8 9 10 8 11 7 13 5 13 5 12 6 11 7 9 9 8 10 7 11 7 11 7 11 7 11 7 11 7 11 7 11 7 11 7 11 7 11 7 11 7 11 7 11 7 11 8 5 1 1 2 1 8 11 6 12 5 13 4 14 2 16 2 46
+
+
+
+
+
+ 58 1 6 1 6 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 3 2 5 2 4 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 6 1 6 1 6 1 6 1 6 1 6 1 6 1 6 1 5 2 6 1 6 1 6 1 6 1 6 1 6 1 10
+
+
+
+
+
+ 8 1 3 2 5 2 1 1 4 2 133 2 31 5 29 7 27 9 25 12 23 13 22 12 23 12 23 11 24 9 26 7 20 3 5 6 19 5 5 6 18 7 4 6 17 8 3 7 17 9 2 7 16 10 2 7 15 21 13 22 18 16 19 7 2 7 19 6 4 6 20 3 6 6 29 6 28 7 28 7 28 6 29 6 29 6 29 6 29 6 29 6 29 6 29 5 30 4 30 4 31 3 65 2 2 28 2 3 3 1 261
+
+
+
+
+
+ 187 1 37 4 35 6 33 8 31 11 28 14 26 16 24 16 4 4 16 15 25 12 28 8 32 8 32 7 33 7 33 7 33 7 33 7 33 7 34 6 33 7 33 7 34 6 34 6 33 7 33 7 33 7 22 4 8 6 21 8 1 2 1 7 19 62 16 23 17 23 23 17 28 12 30 1 3 5 35 5 35 4 36 4 36 3 37 2 16
+
+
+
+
+
+ 68 3 22 5 21 6 20 8 18 9 18 9 19 8 19 9 1 1 16 10 18 8 19 7 21 5 22 4 24 2 93
+
+
+
+
+
+ 113 1 30 9 4 2 7 1 53 1 17 1 8 1 8 1 17 1 8 1 8 1 8 1 8 1 8 1 17 1 7 4 6 1 8 1 8 1 22
+
+
+
+
+
+ 252 2 11 2 30 8 4 4 28 18 27 19 26 21 25 21 8 4 9 1 2 26 1 3 17 8 2 11 25 7 6 7 26 7 6 7 26 7 6 7 26 7 6 7 26 7 6 6 27 7 6 7 26 7 6 9 24 7 6 9 24 7 5 13 21 7 4 13 22 7 6 10 24 6 8 6 26 6 9 3 28 6 35 1 3 7 33 2 5 6 31 3 6 6 30 3 6 7 29 9 1 7 22 72 1 1 1 1 19 18 28 17 35 9 42 3 305
+
+
+
+
+
+ 174 2 28 5 24 10 21 16 14 19 12 25 7 19 13 7 3 8 14 6 5 7 14 6 5 6 15 6 5 5 16 7 4 6 15 7 4 8 13 7 4 9 12 7 3 10 3 1 8 7 3 11 11 7 4 10 11 7 5 8 12 7 7 4 14 7 7 3 15 7 25 7 25 7 25 7 25 7 25 6 21 1 2 1 1 6 1 11 1 40 5 2 185
+
+
+
+
+
+ 50 2 1 10 92 4 16 5 16 7 12 10 2 1 8 12 9 11 10 10 11 10 11 9 12 7 14 7 14 7 14 7 14 7 14 7 14 7 11 2 1 7 9 12 14 7 14 7 14 7 14 7 14 7 14 7 14 6 15 6 15 6 15 6 15 5 16 4 17 3 18 2 77
+
+
+
+
+
+ 28 1 16 3 15 5 13 6 12 8 11 10 8 14 2 2 1 16 4 10 9 9 10 8 11 7 13 6 13 6 13 7 11 7 12 7 12 7 12 7 12 7 13 6 13 6 12 7 12 7 12 7 12 7 12 7 12 7 8 20 1 9 4 3 6 4 14 4 15 3 16 2 89
+
+
+
+
+
+ 64 1 16 2 15 4 13 6 11 8 9 9 9 11 7 11 7 10 8 9 9 8 10 7 11 6 12 6 12 6 12 6 7 36 5 6 12 6 12 6 12 6 12 6 12 6 12 6 12 6 12 6 12 6 12 6 12 5 13 4 13 4 14 3 16 2 65
+
+
+
+
+
+ 65 1 16 3 15 5 13 7 11 9 10 12 5 13 5 16 1 21 3 8 11 7 12 7 13 6 13 6 13 6 13 6 13 6 13 6 13 6 13 6 13 6 13 6 13 6 13 6 13 6 13 6 13 6 13 5 12 36 4 3 88
+
+
+
+
+
+ 10 2 19 3 18 5 16 7 14 9 12 14 2 2 4 17 5 12 11 10 12 9 13 8 14 7 15 7 15 7 15 7 15 7 15 7 15 6 16 7 15 6 16 6 16 7 15 7 15 6 16 6 16 6 16 7 1 1 7 23 1 11 15 4 18 3 19 2 58
+
+
+
+
+
+ 176 2 2 2 4 12 214 1 5 11 5 1 82
+
+
+
+
+
+ 64 1 15 4 13 5 12 6 11 8 9 10 8 12 6 13 1 18 5 8 10 7 11 7 11 7 11 7 12 6 12 6 11 7 12 6 12 6 12 6 12 6 12 6 11 7 11 7 11 6 12 6 12 6 12 6 10 15 4 6 13 4 14 3 15 2 16 1 48
+
+
+
+
+
+ 66 3 23 7 19 16 10 18 9 53 3 20 8 6 6 7 9 6 6 7 9 7 6 5 10 7 6 5 10 6 7 5 10 6 6 6 10 6 6 7 9 6 6 8 2 2 4 6 5 12 5 6 5 10 7 6 5 9 8 6 6 7 9 6 7 5 10 6 8 3 11 6 22 6 22 5 23 5 20 1 1 26 3 3 25 2 26 1 136
+
+
+
+
+
+ 0 2 4 2 57 3 15 4 14 6 12 8 3 2 5 10 1 3 5 14 5 13 6 12 7 11 8 10 9 8 11 7 12 7 12 7 12 7 12 7 12 7 8 19 1 2 1 9 3 1 6 7 12 7 12 7 12 7 12 7 12 6 13 6 13 6 13 6 13 6 13 6 13 6 13 5 14 4 15 3 16 2 51
+
+
+
+
+
+ 127 2 8 2 23 7 3 5 21 16 20 17 20 17 20 19 1 42 12 7 3 7 20 7 3 6 21 6 5 4 22 6 4 6 16 1 4 6 4 7 14 2 4 6 4 8 11 6 2 6 3 10 9 15 3 11 7 15 4 10 7 16 6 7 8 15 7 6 13 9 10 4 17 5 12 2 20 2 136
+
+
+
+
+
+ 52 2 18 4 15 6 14 9 11 11 10 14 7 14 8 12 9 11 10 10 11 9 12 7 14 7 14 6 15 6 15 6 15 6 15 6 15 6 15 6 15 6 15 6 15 6 15 6 15 6 14 7 9 2 2 11 4 1 7 6 15 5 16 4 17 4 17 2 19 1 56
+
+
+
+
+
+ 202 1 10 1 43 1 132 1 10 1 16 1 2 3 2 3 5 1 43 1 10 1 10 1 38
+
+
+
+
+
+ 80 2 12 3 11 5 9 6 8 10 5 13 1 30 1 10 5 8 7 7 8 7 8 7 8 7 8 7 8 7 8 7 9 6 9 6 9 6 9 6 8 7 8 6 9 6 9 6 9 6 9 6 9 6 9 6 9 6 9 4 11 3 12 2 42
+
+
+
+
+
+ 0 1 102 1 20 4 18 5 17 6 15 8 15 9 13 10 13 1 1 8 15 10 14 8 15 7 17 5 18 3 21 1 82
+
+
+
+
+
+ 63 3 15 4 14 6 12 8 9 37 2 13 6 12 7 12 7 10 9 9 10 8 11 7 12 7 12 7 12 7 13 6 13 6 13 6 13 6 13 6 13 7 11 8 12 6 13 6 12 7 12 12 7 7 13 6 13 6 12 7 13 5 14 5 14 4 15 3 15 3 109
+
+
+
+
+
+ 0 12 1 3 3 2 44 1 23 4 21 6 19 7 18 9 17 11 14 14 12 13 13 12 15 10 16 8 18 7 18 8 19 7 19 7 19 7 19 7 19 7 18 8 18 8 19 7 19 7 19 7 19 7 19 6 20 6 20 6 20 6 20 5 21 4 22 3 23 2 24 2 120
+
+
+
+
+
+ 196 4 25 9 2 2 17 15 14 17 12 28 3 19 13 6 4 7 14 6 4 7 14 6 5 6 13 7 4 7 13 7 4 6 14 7 4 6 14 7 4 6 14 7 4 9 11 7 4 10 10 7 3 15 6 6 4 13 8 6 7 9 10 5 8 6 11 6 8 5 12 6 9 3 13 6 10 1 14 6 25 6 24 7 22 1 1 8 23 6 26 4 27 2 29 1 120
+
+
+
+
+
+ 63 2 23 3 21 6 19 8 17 10 15 13 12 16 11 15 12 13 13 12 14 11 15 7 19 7 19 7 19 7 13 1 1 12 3 1 5 3 6 7 19 7 19 7 19 7 19 7 19 7 19 6 20 6 20 6 20 6 20 6 20 6 20 6 20 6 20 6 20 4 22 3 23 2 45 19 2 4 52
+
+
+
+
+
+ 69 1 17 2 15 4 14 5 13 7 10 9 5 2 1 14 2 18 5 14 7 11 8 10 9 9 10 7 12 7 11 8 11 8 11 8 11 8 11 8 11 7 12 7 12 7 12 7 12 7 12 7 12 7 11 8 7 15 2 14 11 7 13 6 12 6 14 3 16 2 17 1 127
+
+
+
+
+
+ 6 26 38 1 40 2 39 4 36 6 36 6 35 8 33 11 30 13 30 12 30 10 32 9 33 7 35 7 35 6 27 4 5 6 26 5 4 7 25 6 5 6 24 7 5 6 23 9 3 7 17 1 3 13 1 8 15 3 1 23 20 1 3 9 1 7 25 7 3 7 25 6 4 7 26 3 6 7 26 2 7 7 35 7 35 7 35 7 35 7 35 7 35 7 35 7 35 7 35 6 36 5 37 3 39 3 39 2 61 5 2 14 1 3 2 1 1 5 50
+
+
+
+
+
+ 49 1 11 4 9 6 7 8 5 10 4 11 2 13 2 11 3 10 4 9 5 8 6 7 7 7 7 7 7 7 7 7 7 2 1 4 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 9 5 1 1 7 7 8 3 1 2 7 7 6 8 4 10 3 11 2 12 2 24
+
+
+
+
+
+ 24 1 5 2 5 2 5 2 6 2 5 2 5 1 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 3 4 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 5 2 6 1 6 1 6 1 6 1 6 1 6 9 4 2 24
+
+
+
diff --git a/core/tests/fixtures/Interactive_Classifier_GameraXML_TrainingData.xml b/core/tests/fixtures/Square_notation-example_training_data.xml
similarity index 100%
rename from core/tests/fixtures/Interactive_Classifier_GameraXML_TrainingData.xml
rename to core/tests/fixtures/Square_notation-example_training_data.xml
diff --git a/core/tests/sample_input/Hufnagel-example.png b/core/tests/sample_input/Hufnagel-example.png
new file mode 100644
index 0000000..04dade6
Binary files /dev/null and b/core/tests/sample_input/Hufnagel-example.png differ
diff --git a/core/tests/sample_input/Hufnagel-example_annotations.json b/core/tests/sample_input/Hufnagel-example_annotations.json
new file mode 100644
index 0000000..6dc35a5
--- /dev/null
+++ b/core/tests/sample_input/Hufnagel-example_annotations.json
@@ -0,0 +1,1083 @@
+{
+ "imageName": "Hufnagel-example.png",
+ "annotations": [
+ {
+ "id": "4a04eb16-8344-5aa4-8551-0032119afe36",
+ "classId": 2,
+ "bbox": [
+ 196,
+ 122,
+ 27,
+ 42
+ ],
+ "type": "f-clef"
+ },
+ {
+ "id": "fecb0091-dd56-5975-88d8-281a93f5bb84",
+ "classId": 2,
+ "bbox": [
+ 195,
+ 230,
+ 26,
+ 50
+ ],
+ "type": "f-clef"
+ },
+ {
+ "id": "3d1cca4e-0219-5574-97a2-c4cc7b357f20",
+ "classId": 2,
+ "bbox": [
+ 196,
+ 351,
+ 26,
+ 46
+ ],
+ "type": "f-clef"
+ },
+ {
+ "id": "943706dd-e0f0-5b4e-a7ad-c88920a7aa2c",
+ "classId": 2,
+ "bbox": [
+ 192,
+ 485,
+ 31,
+ 47
+ ],
+ "type": "f-clef"
+ },
+ {
+ "id": "417442c6-10ab-5961-99aa-9a5b8df439ae",
+ "classId": 2,
+ "bbox": [
+ 199,
+ 608,
+ 27,
+ 49
+ ],
+ "type": "f-clef"
+ },
+ {
+ "id": "538d54a2-111e-534e-bfdf-5d0a2e282e04",
+ "classId": 2,
+ "bbox": [
+ 199,
+ 735,
+ 26,
+ 43
+ ],
+ "type": "f-clef"
+ },
+ {
+ "id": "ef1b7143-5b88-5eb4-8555-160119c9deef",
+ "classId": 2,
+ "bbox": [
+ 665,
+ 722,
+ 39,
+ 35
+ ],
+ "type": "custos"
+ },
+ {
+ "id": "0febf256-7b5c-5b7d-8436-e3c293594477",
+ "classId": 2,
+ "bbox": [
+ 654,
+ 601,
+ 42,
+ 30
+ ],
+ "type": "custos"
+ },
+ {
+ "id": "4048cafb-1913-5cb3-a3ca-215d0bbae802",
+ "classId": 2,
+ "bbox": [
+ 650,
+ 474,
+ 46,
+ 35
+ ],
+ "type": "custos"
+ },
+ {
+ "id": "2b54eef1-50f5-563a-bd31-8b10d6384e48",
+ "classId": 2,
+ "bbox": [
+ 646,
+ 337,
+ 38,
+ 34
+ ],
+ "type": "custos"
+ },
+ {
+ "id": "c7326f8e-cedd-5e09-8179-4fa4137de0b9",
+ "classId": 2,
+ "bbox": [
+ 645,
+ 214,
+ 45,
+ 33
+ ],
+ "type": "custos"
+ },
+ {
+ "id": "dc897b60-55f0-53e9-8376-1718119471ad",
+ "classId": 2,
+ "bbox": [
+ 639,
+ 108,
+ 41,
+ 31
+ ],
+ "type": "custos"
+ },
+ {
+ "id": "fa62d64f-ac06-5fec-9735-599cbbc96fc1",
+ "classId": 2,
+ "bbox": [
+ 221,
+ 125,
+ 23,
+ 47
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "e8e9a383-1972-51d8-9b06-7caa97c2a278",
+ "classId": 2,
+ "bbox": [
+ 245,
+ 112,
+ 23,
+ 46
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "6c40dd41-1571-5115-b64d-72222c402ca6",
+ "classId": 2,
+ "bbox": [
+ 268,
+ 102,
+ 22,
+ 43
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "7f5e4f5c-77b5-5fcb-81b1-557713a7cee6",
+ "classId": 2,
+ "bbox": [
+ 297,
+ 111,
+ 30,
+ 47
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "d959d562-e772-55a9-b56b-1dfea58ad53d",
+ "classId": 2,
+ "bbox": [
+ 341,
+ 122,
+ 35,
+ 41
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "33f17a20-62f0-5f86-ac2d-9b38372d9e75",
+ "classId": 2,
+ "bbox": [
+ 382,
+ 122,
+ 30,
+ 31
+ ],
+ "type": "puncta"
+ },
+ {
+ "id": "758b37eb-c6e1-5c03-9dcf-17f8a2bbd8b0",
+ "classId": 2,
+ "bbox": [
+ 410,
+ 125,
+ 35,
+ 20
+ ],
+ "type": "puncta"
+ },
+ {
+ "id": "0927fd27-5eb0-5006-adc6-2d7eb46672f1",
+ "classId": 2,
+ "bbox": [
+ 439,
+ 123,
+ 31,
+ 40
+ ],
+ "type": "pes"
+ },
+ {
+ "id": "25b726c9-c928-551d-adb6-07d63d8d59a8",
+ "classId": 2,
+ "bbox": [
+ 470,
+ 110,
+ 19,
+ 45
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "3b4657f8-5c29-5d67-958c-31bba0a8311c",
+ "classId": 2,
+ "bbox": [
+ 491,
+ 126,
+ 28,
+ 33
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "46d61661-fab1-5e43-a117-52abdcf3553b",
+ "classId": 2,
+ "bbox": [
+ 517,
+ 121,
+ 40,
+ 37
+ ],
+ "type": "torculus"
+ },
+ {
+ "id": "b5135cff-e70a-582a-b120-c397be7edf70",
+ "classId": 2,
+ "bbox": [
+ 561,
+ 138,
+ 22,
+ 42
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "4ae48e7e-cdd5-5434-bf7c-7dba36df8a4a",
+ "classId": 2,
+ "bbox": [
+ 601,
+ 138,
+ 35,
+ 41
+ ],
+ "type": "pes"
+ },
+ {
+ "id": "1d6a3ecd-c88d-52a7-9aad-072a579ac29e",
+ "classId": 2,
+ "bbox": [
+ 222,
+ 228,
+ 42,
+ 45
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "9cd2c72d-eaf3-5061-9947-54544c8251f1",
+ "classId": 2,
+ "bbox": [
+ 274,
+ 239,
+ 28,
+ 19
+ ],
+ "type": "puncta"
+ },
+ {
+ "id": "d2062c20-596d-57ab-beb4-241d2ebea756",
+ "classId": 2,
+ "bbox": [
+ 319,
+ 237,
+ 19,
+ 42
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "68fe3d99-e597-5b30-8c89-31b6d750adac",
+ "classId": 2,
+ "bbox": [
+ 341,
+ 237,
+ 30,
+ 35
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "de6261f1-8f64-5c61-8ec0-7473553409c9",
+ "classId": 2,
+ "bbox": [
+ 375,
+ 227,
+ 20,
+ 44
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "d04c6504-c258-5ca8-9a79-bfff05b25a21",
+ "classId": 2,
+ "bbox": [
+ 395,
+ 217,
+ 29,
+ 47
+ ],
+ "type": "pes"
+ },
+ {
+ "id": "335557ed-9770-572a-a135-562433e0931a",
+ "classId": 2,
+ "bbox": [
+ 419,
+ 227,
+ 45,
+ 30
+ ],
+ "type": "torculus"
+ },
+ {
+ "id": "b362737b-e468-52ba-a36f-a85e372a4881",
+ "classId": 2,
+ "bbox": [
+ 467,
+ 234,
+ 23,
+ 38
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "024e4453-3120-57cf-b39b-93390df27547",
+ "classId": 2,
+ "bbox": [
+ 485,
+ 224,
+ 12,
+ 47
+ ],
+ "type": "divisio"
+ },
+ {
+ "id": "c41d2cbb-894a-53d2-b679-71ce24a89cc0",
+ "classId": 2,
+ "bbox": [
+ 496,
+ 216,
+ 38,
+ 44
+ ],
+ "type": "scandicus"
+ },
+ {
+ "id": "05ce7cea-a3ce-52b6-97b8-5afeae19fd2e",
+ "classId": 2,
+ "bbox": [
+ 525,
+ 207,
+ 26,
+ 47
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "6f98e613-0002-5f6b-9f07-ae975105e83e",
+ "classId": 2,
+ "bbox": [
+ 549,
+ 216,
+ 21,
+ 18
+ ],
+ "type": "puncta"
+ },
+ {
+ "id": "6def46b3-65de-5731-b7a4-ab8508b3dc2b",
+ "classId": 2,
+ "bbox": [
+ 575,
+ 217,
+ 20,
+ 19
+ ],
+ "type": "puncta"
+ },
+ {
+ "id": "befb3b0a-6628-5118-864b-256371cc6155",
+ "classId": 2,
+ "bbox": [
+ 613,
+ 216,
+ 23,
+ 45
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "59d73918-1bcc-520d-89cb-be27ee67b3fc",
+ "classId": 2,
+ "bbox": [
+ 224,
+ 349,
+ 24,
+ 43
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "b495894e-f6fd-5e83-8f7c-0844a5e72ca7",
+ "classId": 2,
+ "bbox": [
+ 271,
+ 341,
+ 33,
+ 42
+ ],
+ "type": "pes"
+ },
+ {
+ "id": "1c070b22-dbef-5674-89b4-66818a090eaf",
+ "classId": 2,
+ "bbox": [
+ 309,
+ 348,
+ 18,
+ 44
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "c77f6c3f-f770-568e-9ad6-75a3f3c447da",
+ "classId": 2,
+ "bbox": [
+ 337,
+ 356,
+ 21,
+ 21
+ ],
+ "type": "puncta"
+ },
+ {
+ "id": "31342466-a013-53d4-932f-b4d7d81bf38e",
+ "classId": 2,
+ "bbox": [
+ 353,
+ 354,
+ 8,
+ 40
+ ],
+ "type": "divisio"
+ },
+ {
+ "id": "b555e3c9-d86a-5436-8e88-2e6aa3ba7226",
+ "classId": 2,
+ "bbox": [
+ 355,
+ 360,
+ 33,
+ 36
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "ed7bbe04-4a1d-5c57-b1b8-43c6a46e09d2",
+ "classId": 2,
+ "bbox": [
+ 389,
+ 347,
+ 19,
+ 45
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "0c2b59c8-efab-5dd3-a5dc-96543abf2f0f",
+ "classId": 2,
+ "bbox": [
+ 417,
+ 355,
+ 37,
+ 43
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "659fa55c-95d0-5b52-9280-7e176552081b",
+ "classId": 2,
+ "bbox": [
+ 449,
+ 359,
+ 41,
+ 37
+ ],
+ "type": "torculus"
+ },
+ {
+ "id": "1753d34a-1934-5e07-8621-025deca27578",
+ "classId": 2,
+ "bbox": [
+ 476,
+ 388,
+ 14,
+ 33
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "9d977ecc-f780-55b1-893b-6b84405a505b",
+ "classId": 2,
+ "bbox": [
+ 486,
+ 356,
+ 8,
+ 61
+ ],
+ "type": "divisio"
+ },
+ {
+ "id": "a98d53b1-e774-5ceb-900e-17bbbb54fe50",
+ "classId": 2,
+ "bbox": [
+ 493,
+ 374,
+ 18,
+ 43
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "c0a81e9f-38ea-56a8-a52b-b053d288d8e9",
+ "classId": 2,
+ "bbox": [
+ 525,
+ 385,
+ 25,
+ 21
+ ],
+ "type": "puncta"
+ },
+ {
+ "id": "515e6fa5-c224-597a-8ab3-e18a30923f88",
+ "classId": 2,
+ "bbox": [
+ 557,
+ 377,
+ 23,
+ 38
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "41c86b4b-cff3-53bc-a6a8-31aa543bd9d2",
+ "classId": 2,
+ "bbox": [
+ 594,
+ 356,
+ 18,
+ 37
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "8c8879ed-5d73-51b7-a382-bcb8ff664767",
+ "classId": 2,
+ "bbox": [
+ 623,
+ 354,
+ 22,
+ 41
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "7c53906f-4f35-5c18-9a8c-7be226e301e7",
+ "classId": 2,
+ "bbox": [
+ 219,
+ 495,
+ 28,
+ 34
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "0c131237-c719-5774-881f-a30ce3ffaf59",
+ "classId": 2,
+ "bbox": [
+ 249,
+ 511,
+ 32,
+ 38
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "f1762e20-cf6e-5dd9-b5e1-f79cc772eafb",
+ "classId": 2,
+ "bbox": [
+ 289,
+ 488,
+ 18,
+ 44
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "a75e64ee-d45d-5f3e-a595-be3d9a0a99c7",
+ "classId": 2,
+ "bbox": [
+ 311,
+ 478,
+ 42,
+ 30
+ ],
+ "type": "torculus"
+ },
+ {
+ "id": "ad1e92b0-bb91-5c3f-aee0-712394b99e52",
+ "classId": 2,
+ "bbox": [
+ 347,
+ 484,
+ 12,
+ 41
+ ],
+ "type": "divisio"
+ },
+ {
+ "id": "84e92883-2b4c-50bf-9abb-86ac64687bb0",
+ "classId": 2,
+ "bbox": [
+ 359,
+ 489,
+ 20,
+ 36
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "b95ecb96-691e-5d67-bbb9-7f8f5feb1ca3",
+ "classId": 2,
+ "bbox": [
+ 386,
+ 471,
+ 25,
+ 39
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "6e795040-0cb2-52bc-b4b6-21ddf0dcf1f1",
+ "classId": 2,
+ "bbox": [
+ 428,
+ 479,
+ 22,
+ 20
+ ],
+ "type": "puncta"
+ },
+ {
+ "id": "bc2e97c9-f194-5219-b2cb-4470a1ea14f9",
+ "classId": 2,
+ "bbox": [
+ 464,
+ 467,
+ 33,
+ 42
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "e0b42472-4f3f-51f1-a73e-73ae02b883f6",
+ "classId": 2,
+ "bbox": [
+ 512,
+ 490,
+ 30,
+ 40
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "b769e131-6c48-59c7-a357-2443929548ce",
+ "classId": 2,
+ "bbox": [
+ 553,
+ 476,
+ 18,
+ 43
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "262d9cfd-1de2-57f3-81b3-f65f32bd160b",
+ "classId": 2,
+ "bbox": [
+ 574,
+ 470,
+ 31,
+ 44
+ ],
+ "type": "pes"
+ },
+ {
+ "id": "4cbcade4-5fd9-5a04-9f0a-d93c7d942b25",
+ "classId": 2,
+ "bbox": [
+ 602,
+ 480,
+ 53,
+ 29
+ ],
+ "type": "torculus"
+ },
+ {
+ "id": "10b7a54f-a95a-5066-85b8-4b2ea062471c",
+ "classId": 2,
+ "bbox": [
+ 631,
+ 611,
+ 18,
+ 38
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "5185d34e-6e61-5c95-b30d-5e1fca898a7f",
+ "classId": 2,
+ "bbox": [
+ 649,
+ 600,
+ 7,
+ 49
+ ],
+ "type": "divisio"
+ },
+ {
+ "id": "0d8fee00-cb28-53ec-8b76-0c19e08d508f",
+ "classId": 2,
+ "bbox": [
+ 586,
+ 599,
+ 35,
+ 51
+ ],
+ "type": "pes"
+ },
+ {
+ "id": "b91c088b-f714-515c-9174-96d6858a577e",
+ "classId": 2,
+ "bbox": [
+ 547,
+ 610,
+ 40,
+ 44
+ ],
+ "type": "pes"
+ },
+ {
+ "id": "207dfd70-96f7-5863-96a1-289a3ac8110f",
+ "classId": 2,
+ "bbox": [
+ 524,
+ 645,
+ 27,
+ 19
+ ],
+ "type": "puncta"
+ },
+ {
+ "id": "e4ab7408-5484-5759-8c15-d3559a1cd051",
+ "classId": 2,
+ "bbox": [
+ 499,
+ 627,
+ 9,
+ 44
+ ],
+ "type": "divisio"
+ },
+ {
+ "id": "6673581e-f91f-531f-8299-c5dd62b8ebc4",
+ "classId": 2,
+ "bbox": [
+ 411,
+ 612,
+ 46,
+ 44
+ ],
+ "type": "torculus"
+ },
+ {
+ "id": "79a0b0ae-c4c1-5466-ba4e-3438580cc54a",
+ "classId": 2,
+ "bbox": [
+ 376,
+ 613,
+ 32,
+ 39
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "09786a0f-a045-56e0-ab50-b9af343c614e",
+ "classId": 2,
+ "bbox": [
+ 352,
+ 600,
+ 21,
+ 42
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "d5e891f8-2934-528f-a5ea-e57d263a492e",
+ "classId": 2,
+ "bbox": [
+ 319,
+ 616,
+ 19,
+ 39
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "ff185dae-d64b-589f-a04c-ecd7caaac816",
+ "classId": 2,
+ "bbox": [
+ 279,
+ 626,
+ 18,
+ 40
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "d9871c49-36e4-5ab8-8873-ea144341055a",
+ "classId": 2,
+ "bbox": [
+ 252,
+ 614,
+ 19,
+ 38
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "56f1cf18-43c6-52b5-a41d-301d8f34eb07",
+ "classId": 2,
+ "bbox": [
+ 222,
+ 619,
+ 22,
+ 34
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "b93e0fd9-edb9-53b7-bf65-2a837ee28401",
+ "classId": 2,
+ "bbox": [
+ 239,
+ 608,
+ 11,
+ 47
+ ],
+ "type": "divisio"
+ },
+ {
+ "id": "505afe87-b8ef-5cfa-84a3-f0120ef1be18",
+ "classId": 2,
+ "bbox": [
+ 225,
+ 741,
+ 18,
+ 39
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "ef0be44a-28cf-531c-bcb8-68ad3eeb94c4",
+ "classId": 2,
+ "bbox": [
+ 245,
+ 745,
+ 28,
+ 35
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "d79cff2f-d8a7-51ba-9f26-885f7cfe392c",
+ "classId": 2,
+ "bbox": [
+ 287,
+ 731,
+ 19,
+ 40
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "882dcad1-4d8e-517e-9bbe-0abaf7a5c4de",
+ "classId": 2,
+ "bbox": [
+ 306,
+ 721,
+ 37,
+ 27
+ ],
+ "type": "torculus"
+ },
+ {
+ "id": "3ee2803c-b721-562d-ad6b-9c1f7c38b5a2",
+ "classId": 2,
+ "bbox": [
+ 345,
+ 743,
+ 21,
+ 37
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "283043b6-07b6-5717-9e9f-e13d1e9ed85b",
+ "classId": 2,
+ "bbox": [
+ 364,
+ 733,
+ 11,
+ 49
+ ],
+ "type": "divisio"
+ },
+ {
+ "id": "658be067-4409-586e-821c-236d53bcc3d0",
+ "classId": 2,
+ "bbox": [
+ 387,
+ 718,
+ 15,
+ 40
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "676e7716-70e4-5648-8a8c-c27f1c3883d1",
+ "classId": 2,
+ "bbox": [
+ 420,
+ 730,
+ 23,
+ 21
+ ],
+ "type": "puncta"
+ },
+ {
+ "id": "adcb53e0-a51c-58aa-b2d9-d8858fce87b1",
+ "classId": 2,
+ "bbox": [
+ 455,
+ 721,
+ 19,
+ 44
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "2135eb46-3933-5839-999e-00451b2d16da",
+ "classId": 2,
+ "bbox": [
+ 479,
+ 729,
+ 26,
+ 39
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "fc014e59-16ef-5c94-8e00-7f6ab3b413f3",
+ "classId": 2,
+ "bbox": [
+ 506,
+ 739,
+ 31,
+ 39
+ ],
+ "type": "clivis"
+ },
+ {
+ "id": "87f28a55-e2bd-5541-a073-aa7007675c37",
+ "classId": 2,
+ "bbox": [
+ 540,
+ 732,
+ 26,
+ 40
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "0f18989b-b074-5a1f-ad8b-800fd035087d",
+ "classId": 2,
+ "bbox": [
+ 577,
+ 718,
+ 19,
+ 44
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "0e5b868e-eb58-509f-8c44-9c655ce0b3d5",
+ "classId": 2,
+ "bbox": [
+ 598,
+ 727,
+ 42,
+ 43
+ ],
+ "type": "pes"
+ },
+ {
+ "id": "db434cbe-7139-5e5d-af19-0fa1466400ca",
+ "classId": 2,
+ "bbox": [
+ 644,
+ 738,
+ 14,
+ 39
+ ],
+ "type": "virga"
+ },
+ {
+ "id": "81492f21-9d60-5ded-b815-9bfee75e4b56",
+ "classId": 2,
+ "bbox": [
+ 657,
+ 727,
+ 7,
+ 47
+ ],
+ "type": "divisio"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/core/tests/sample_input/__init__.py b/core/tests/sample_input/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/tests/sample_input/csv-square_notation_neume_level_newest.csv b/core/tests/sample_input/csv-square_notation_neume_level_newest.csv
new file mode 100644
index 0000000..b32a664
--- /dev/null
+++ b/core/tests/sample_input/csv-square_notation_neume_level_newest.csv
@@ -0,0 +1,154 @@
+imagePath,imagesBinary,name,folio,description,classification,width,mei,review,dob,project,
+[],[],C clef,001r,clef.c,clef.c,1,"",No,,5d39e8755fbd53216a2fa442,
+[],[],F Clef,001r,,clef.f,1,"",No,,5d39e8755fbd53216a2fa442,
+[],[],Custos,001r,,custos,1,,No,,5d39e8755fbd53216a2fa442,
+,,Divisio,001r,,divisio.maxima,1,"",,,,
+,,Flat,001r,,accidental.flat,1,"",,,,
+,,Natural,001r,,accidental.natural,1,"",,,,
+[],[],Punctum,001r,,neume.punctum,1,"
+
+",No,,5d39e8755fbd53216a2fa442,
+[],[],Inclinatum,001r,,neume.inclinatum,1,"
+
+",No,,5d39e8755fbd53216a2fa442,
+,,Virga,001r,,neume.virga,1,"
+
+",,,,
+,,Reverse virga,001r,,neume.reversevirga,1,"
+
+",,,,
+,,Upwards liquescent,001r,,neume.liquescent.up,1,"
+
+
+
+",,,,
+,,Downwards liquescent,001r,,neume.liquescent.down,1,"
+
+
+
+",,,,
+[],,"Podatus 2, width 2",001r,podatus salzinnes,neume.podatus2a,"[1, 1]","
+
+
+",No,,5d39e8755fbd53216a2fa442,giving it the width to be able to divide the neume bbox (horizontally) into that number of vertical boxes.
+,,"Podatus 2, width 1",001r,podatus einsiedeln,neume.podatus2b,2,"
+
+
+",No,,,Where should this bbox division into vertical bboxes be done? Heuristic pitch finding (how is that working? because it is getting the pitch of the individual neume-components correcly even having only one bbox for the whole neume) or MEI Encoding job?
+[],[],Podatus 3,001r,,neume.podatus3,2,"
+
+
+",No,,5d39e8755fbd53216a2fa442,"This file is received by the MEI Encoding job, so it can do the mapping of the IC class into both the MEI code (for encoding the ) and the width column (for dividing the bbox and encoding the s)"
+[],[],Podatus 4,001r,,neume.podatus4,2,"
+
+
+",No,,5d39e8755fbd53216a2fa442,
+,,Podatus 5,001r,,neume.podatus5,2,"
+
+
+",,,,
+,,Clivis 2,001r,,neume.clivis2,"[1, 1]","
+
+
+",,,,
+,,Clivis 3,001r,,neume.clivis3,"[1, 1]","
+
+
+",,,,
+,,Clivis 4,001r,,neume.clivis4,"[1, 1]","
+
+
+",,,,
+,,Clivis 5,001r,,neume.clivis5,"[1, 1]","
+
+
+",,,,
+,,Pes cephalicus 2,,,neume.pescephalicus2,"[1, 1]","
+
+
+
+
+",,,,
+,,Pes cephalicus 3,,,neume.pescephalicus3,"[1, 1]","
+
+
+
+
+",,,,
+[],[],Oblique 2,001r,,neume.oblique2,1,"
+
+
+",No,,5d39e8755fbd53216a2fa442,
+[],[],Oblique 3,001r,,neume.oblique3,1,"
+
+
+",No,,5d39e8755fbd53216a2fa442,
+[],[],Oblique 4,001r,,neume.oblique4,1,"
+
+
+",No,,5d39e8755fbd53216a2fa442,
+[],[],Torculus22,001r,,neume.torculus22,"[1, 1, 1]","
+
+
+
+",No,,5d39e8755fbd53216a2fa442,
+,,Torculus23,001r,,neume.torculus23,"[1, 1, 1]","
+
+
+
+",,,,
+,,Torculus24,001r,,neume.torculus24,"[1, 1, 1]","
+
+
+
+",,,,
+,,Torculus32,001r,,neume.torculus32,"[1, 1, 1]","
+
+
+
+",,,,
+,,Torculus33,001r,,neume.torculus33,"[1, 1, 1]","
+
+
+
+",,,,
+,,Torculus34,001r,,neume.torculus34,"[1, 1, 1]","
+
+
+
+",,,,
+,,Torculus42,001r,,neume.torculus42,"[1, 1, 1]","
+
+
+
+",,,,
+,,Torculus43,001r,,neume.torculus43,"[1, 1, 1]","
+
+
+
+",,,,
+,,Scandicus 22,001r,,neume.scandicus22a,"[1, 2]","
+
+
+
+",,,,CRES would need to check that # of == # in width column
+,,Scandicus 22,001r,,neume.scandicus22b,"[2, 1]","
+
+
+
+",,,,
+,,Scandicus 22,001r,,neume.scandicus22c,"[1, 1, 1]","
+
+
+
+",,,,
+,,Scandicus 23,001r,,neume.scandicus23,"[1, 2]","
+
+
+
+",,,,
+,,Scandicus 33,001r,,neume.scandicus33,"[1, 2]","
+
+
+
+",,,,
\ No newline at end of file
diff --git a/core/tests/sample_input/helpers/__init__.py b/core/tests/sample_input/helpers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/tests/sample_input/helpers/convert_hufnagel_csv.py b/core/tests/sample_input/helpers/convert_hufnagel_csv.py
new file mode 100644
index 0000000..62b9144
--- /dev/null
+++ b/core/tests/sample_input/helpers/convert_hufnagel_csv.py
@@ -0,0 +1,204 @@
+"""Convert a VIA-format Hufnagel CSV + page image into a GameraXML training file.
+
+The Hufnagel annotation CSV (e.g. ``fixtures/Hufnagel-example.csv``) is a
+VIA export: one bbox per row plus a free-form ``type`` label. We crop each
+bbox out of the companion page image, binarise, and write the result as a
+GameraXML database matching the schema of
+``fixtures/Interactive_Classifier_GameraXML_TrainingData.xml`` — so the
+new glyphs can be loaded by :func:`ic_core.io_xml.load_glyphs` and used as
+KNN training data.
+
+Class labels in the Hufnagel CSV (``f-clef``, ``puncta``, ``pes``, …)
+don't all line up with the existing GameraXML vocabulary. The
+:data:`HUFNAGEL_TO_GAMERA` table translates the recognised ones and passes
+the rest through verbatim — edit it as mapping decisions are made.
+
+Run::
+
+ cd core/ic_core && uv run python ../tests/sample_input/helpers/convert_hufnagel_csv.py
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import uuid
+from collections import Counter
+from dataclasses import replace
+from pathlib import Path
+
+from ic_core.glyph import Glyph
+from ic_core.ingest import ingest_page
+from ic_core.io_xml import write_glyphs
+
+HERE = Path(__file__).parent
+SAMPLE_DIR = HERE.parent
+FIXTURES = SAMPLE_DIR.parent / "fixtures"
+
+DEFAULT_CSV = FIXTURES / "Hufnagel-example.csv"
+DEFAULT_PAGE = SAMPLE_DIR / "Hufnagel-example.png"
+DEFAULT_OUTPUT_XML = FIXTURES / "Hufnagel-example_training_data.xml"
+#: MOTHRA-shaped JSON sidecar for visualize.py / ingest_page_json
+#: consumers. Lives next to the page image so it's picked up by
+#: relative paths like SAMPLE_DIR / "Hufnagel-example_annotations.json".
+DEFAULT_OUTPUT_JSON = SAMPLE_DIR / "Hufnagel-example_annotations.json"
+
+#: Map Hufnagel CSV ``type`` strings to GameraXML vocabulary. Entries
+#: where the value equals the key are pass-throughs — they emit the raw
+#: Hufnagel label and need a real mapping decision. Edit in place.
+HUFNAGEL_TO_GAMERA: dict[str, str] = {
+ "f-clef": "clef.f",
+ "custos": "custos",
+ "puncta": "neume.punctum",
+ "torculus": "neume.torculus2",
+ "virga": "neume.virga",
+ "pes": "neume.pescephalicus2",
+ "clivis": "neume.clivis2",
+ "scandicus": "neume.scandicus22a",
+ "divisio": "divisio.maxima",
+}
+
+
+def parse_via_csv(csv_path: Path) -> list[tuple[int, int, int, int, str]]:
+ """Return ``(x, y, w, h, raw_class)`` tuples in CSV row order.
+
+ Non-rect shapes are skipped. The CSV's ``filename`` column is
+ informational — we don't dispatch on it, so a multi-file CSV
+ silently merges into one output file. Callers wanting per-file
+ output should filter first.
+ """
+ rows: list[tuple[int, int, int, int, str]] = []
+ with open(csv_path, newline="") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ shape = json.loads(row["region_shape_attributes"])
+ attrs = json.loads(row["region_attributes"])
+ if shape.get("name") != "rect":
+ continue
+ raw_class = (attrs.get("type") or "").strip()
+ if not raw_class:
+ continue
+ rows.append(
+ (
+ int(shape["x"]),
+ int(shape["y"]),
+ int(shape["width"]),
+ int(shape["height"]),
+ raw_class,
+ )
+ )
+ return rows
+
+
+def _stable_ids(csv_path: Path, count: int) -> list[str]:
+ """Derive stable per-row UUIDs from the CSV filename + row index.
+
+ Using uuid5 (deterministic) means re-running the converter doesn't
+ churn the JSON's ids — the sidecar diff stays clean unless the
+ underlying CSV actually changes.
+ """
+ namespace = uuid.uuid5(uuid.NAMESPACE_URL, csv_path.name)
+ return [str(uuid.uuid5(namespace, str(i))) for i in range(count)]
+
+
+def convert(
+ csv_path: Path = DEFAULT_CSV,
+ page_path: Path = DEFAULT_PAGE,
+ output_xml: Path = DEFAULT_OUTPUT_XML,
+ output_json: Path | None = DEFAULT_OUTPUT_JSON,
+) -> list[Glyph]:
+ """Crop the Hufnagel page using the CSV bboxes and write GameraXML.
+
+ Returns the glyphs in CSV order. Each glyph is marked
+ ``id_state_manual=True`` and ``confidence=1.0`` so it serialises
+ as ``state="MANUAL"`` — matching the legacy training database.
+
+ Also writes a MOTHRA-shaped JSON sidecar (unless ``output_json``
+ is ``None``) so ``visualize.py`` and ``ingest_page_json`` can read
+ the same bboxes without re-parsing the CSV. The training XML and
+ the JSON share the per-row uuid5 ids generated by
+ :func:`_stable_ids`, so glyphs ingested from the JSON line up
+ one-to-one with the training XML by id.
+ """
+ annotations = parse_via_csv(csv_path)
+ ids = _stable_ids(csv_path, len(annotations))
+
+ # Hand the bboxes to ingest_page so cropping + binarisation + RLE
+ # encoding use the exact same code path as live ingest. We
+ # synthesise the MOTHRA JSON shape it expects.
+ doc = {
+ "annotations": [
+ {"id": gid, "bbox": [x, y, w, h]}
+ for gid, (x, y, w, h, _) in zip(ids, annotations)
+ ],
+ }
+ glyphs = ingest_page(
+ page_path.read_bytes(),
+ json.dumps(doc).encode("utf-8"),
+ format="json",
+ )
+
+ training: list[Glyph] = []
+ for g, (_, _, _, _, raw_class) in zip(glyphs, annotations):
+ mapped = HUFNAGEL_TO_GAMERA.get(raw_class, raw_class)
+ training.append(
+ replace(
+ g,
+ class_name=mapped,
+ confidence=1.0,
+ id_state_manual=True,
+ is_training=True,
+ )
+ )
+
+ write_glyphs(training, output_xml)
+
+ if output_json is not None:
+ sidecar = {
+ "imageName": page_path.name,
+ "annotations": [
+ {
+ "id": gid,
+ "classId": 2,
+ "bbox": [x, y, w, h],
+ "type": raw_class,
+ }
+ for gid, (x, y, w, h, raw_class) in zip(ids, annotations)
+ ],
+ }
+ output_json.write_text(json.dumps(sidecar, indent=2))
+
+ return training
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--csv", type=Path, default=DEFAULT_CSV)
+ parser.add_argument("--page", type=Path, default=DEFAULT_PAGE)
+ parser.add_argument("--out-xml", type=Path, default=DEFAULT_OUTPUT_XML)
+ parser.add_argument("--out-json", type=Path, default=DEFAULT_OUTPUT_JSON)
+ args = parser.parse_args()
+
+ glyphs = convert(args.csv, args.page, args.out_xml, args.out_json)
+ print(f"Wrote {len(glyphs)} glyphs to {args.out_xml}")
+ print(f"Wrote {len(glyphs)} annotations to {args.out_json}")
+
+ # A class is "pass-through" iff its raw CSV label wasn't really
+ # translated — either absent from the mapping, or present with
+ # value == key. Re-read the raw labels rather than infer from
+ # the (already mapped) glyph.class_name.
+ raw_classes = [raw for *_, raw in parse_via_csv(args.csv)]
+ pass_through_mapped = {
+ HUFNAGEL_TO_GAMERA.get(r, r)
+ for r in raw_classes
+ if HUFNAGEL_TO_GAMERA.get(r, r) == r
+ }
+ counts = Counter(g.class_name for g in glyphs)
+ print("Class histogram:")
+ for name, n in counts.most_common():
+ flag = " (pass-through, needs mapping)" if name in pass_through_mapped else ""
+ print(f" {n:4d} {name}{flag}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/core/tests/sample_input/helpers/evaluate.py b/core/tests/sample_input/helpers/evaluate.py
new file mode 100644
index 0000000..226d071
--- /dev/null
+++ b/core/tests/sample_input/helpers/evaluate.py
@@ -0,0 +1,109 @@
+"""Test-support helpers for the real sample input.
+
+Shared between ``tests/test_real_input_knn.py`` and the
+``visualize.py`` script next door: filtering MOTHRA annotations to
+the glyph-only ``classId == 2`` subset, classifying them against the
+legacy GameraXML training database, and reporting a quick summary.
+
+Lives under ``tests/`` (not ``ic_core/``) because the MOTHRA
+``classId`` is a detector-side concept and ingest is deliberately
+classId-agnostic — see :mod:`ic_core.ingest` module docstring.
+"""
+from __future__ import annotations
+
+import json
+import statistics
+from collections import Counter
+from pathlib import Path
+
+from ic_core.classifier import (
+ InteractiveClassifier,
+ run_correction_stage,
+)
+from ic_core.glyph import Glyph
+from ic_core.ingest import ingest_page
+from ic_core.io_xml import load_glyphs
+
+HERE = Path(__file__).parent
+SAMPLE_DIR = HERE.parent
+PAGE_PATH = SAMPLE_DIR / "NZ-Wt MSR-03 109v.png"
+JSON_PATH = SAMPLE_DIR / "MOTHRA_NZ-Wt MSR-03 109v_annotations.json"
+
+#: classId values in the MOTHRA JSON that mark actual glyphs to
+#: classify. classIds 1 and 3 are non-neume artefacts (staff lines,
+#: stray ink) and are excluded.
+GLYPH_CLASS_ID: int = 2
+
+#: Default training-database fixture.
+# TRAINING_XML_PATH = (
+# SAMPLE_DIR.parent / "fixtures" / "Interactive_Classifier_GameraXML_TrainingData.xml"
+# )
+TRAINING_XML_PATH = (
+ SAMPLE_DIR.parent / "fixtures" / "Hufnagel-example_training_data.xml"
+)
+
+def load_annotations(json_path: Path = JSON_PATH) -> dict:
+ """Return the parsed MOTHRA annotations document."""
+ return json.loads(json_path.read_bytes())
+
+
+def ingest_glyphs_to_classify(
+ page_path: Path = PAGE_PATH,
+ json_path: Path = JSON_PATH,
+) -> list[Glyph]:
+ """Ingest the MOTHRA page, keeping only ``classId == 2`` boxes.
+
+ Filtering is done before handing the JSON to
+ :func:`ic_core.ingest.ingest_page`, so the returned glyphs match
+ the production ingest path exactly (same UUIDs, same binarisation).
+ """
+ doc = load_annotations(json_path)
+ doc["annotations"] = [
+ a for a in doc["annotations"] if a["classId"] == GLYPH_CLASS_ID
+ ]
+ return ingest_page(
+ page_path.read_bytes(),
+ json.dumps(doc).encode("utf-8"),
+ format="json",
+ )
+
+
+def classify_page(
+ page_path: Path = PAGE_PATH,
+ json_path: Path = JSON_PATH,
+ training_xml: Path = TRAINING_XML_PATH,
+) -> tuple[list[Glyph], InteractiveClassifier]:
+ """Run the full ingest → train → classify pipeline on the sample page.
+
+ Returns the classified glyphs (in ingest order) and the trained
+ classifier so callers can inspect the fitted state.
+ """
+ working = ingest_glyphs_to_classify(page_path, json_path)
+ training = load_glyphs(training_xml)
+ return run_correction_stage(working, training)
+
+
+def print_report(glyphs: list[Glyph]) -> None:
+ """Print a one-screen summary of classification results to stdout."""
+ confidences = [g.confidence for g in glyphs]
+ counts = Counter(g.class_name for g in glyphs)
+
+ if glyphs == []:
+ print("No glyphs classified.")
+ return
+
+ print(f"Classified {len(glyphs)} glyphs into {len(counts)} classes.")
+ print(
+ "Confidence: "
+ f"mean={statistics.mean(confidences):.3f} "
+ f"median={statistics.median(confidences):.3f} "
+ f"min={min(confidences):.3f} max={max(confidences):.3f}"
+ )
+ print("Predicted-class histogram (most common first):")
+ for name, n in counts.most_common():
+ print(f" {n:4d} {name}")
+
+
+if __name__ == "__main__":
+ glyphs, _ = classify_page()
+ print_report(glyphs)
diff --git a/core/tests/sample_input/helpers/run_pipeline.py b/core/tests/sample_input/helpers/run_pipeline.py
new file mode 100644
index 0000000..a308b6d
--- /dev/null
+++ b/core/tests/sample_input/helpers/run_pipeline.py
@@ -0,0 +1,105 @@
+"""Single-shot train → classify → visualise pipeline.
+
+Glue script that wires the existing helpers together so a user can
+pick a training set and a test set, run the classifier once, and
+get two overlay PNGs out the other end. No validation: this script
+does not measure accuracy, run cross-validation, or hold out a
+fold — those belong in :mod:`tests.test_real_input_knn`.
+
+The four pieces of state the caller can vary:
+
+* ``--train-xml`` — GameraXML database to train on.
+* ``--test-page`` — page image to classify.
+* ``--test-json`` — MOTHRA-shaped JSON describing the bboxes on the
+ test page. The same JSON drives both ingest (cropping) and the
+ visualisation overlays (drawing boxes).
+* ``--output-dir`` — directory the two overlay PNGs land in. The
+ filenames are derived from the test page's stem:
+ ``_annotated.png`` and ``_predicted.png``.
+
+Defaults inherit from :mod:`evaluate` so running the script with no
+arguments reproduces the same "train Hufnagel, classify MOTHRA"
+configuration that ``evaluate.classify_page()`` uses by default.
+
+Run::
+
+ cd core/ic_core && uv run python ../tests/sample_input/helpers/run_pipeline.py
+ # Or e.g. train + classify the Hufnagel page against itself:
+ uv run python ../tests/sample_input/helpers/run_pipeline.py \\
+ --train-xml ../tests/fixtures/Hufnagel-example_training_data.xml \\
+ --test-page "../tests/sample_input/Hufnagel-example.png" \\
+ --test-json "../tests/sample_input/Hufnagel-example_annotations.json"
+"""
+from __future__ import annotations
+
+import argparse
+from collections import Counter
+from pathlib import Path
+
+from ic_core.classifier import run_correction_stage
+from ic_core.io_xml import load_glyphs
+
+from evaluate import ( # type: ignore[import-not-found]
+ JSON_PATH as DEFAULT_TEST_JSON,
+ PAGE_PATH as DEFAULT_TEST_PAGE,
+ SAMPLE_DIR,
+ TRAINING_XML_PATH as DEFAULT_TRAIN_XML,
+ ingest_glyphs_to_classify,
+)
+from visualize import ( # type: ignore[import-not-found]
+ draw_annotation_overlay,
+ draw_prediction_overlay,
+)
+
+DEFAULT_OUTPUT_DIR = SAMPLE_DIR / "visualization"
+
+
+def run(
+ train_xml: Path,
+ test_page: Path,
+ test_json: Path,
+ output_dir: Path,
+) -> None:
+ """Load training set, classify test page, write both overlay PNGs."""
+ training = load_glyphs(train_xml)
+ print(f"Loaded {len(training)} training glyphs from {train_xml.name}")
+
+ # Filter to classId == 2 (real glyphs) to match the test-suite
+ # ingest path. Non-glyph MOTHRA classes (staff lines, stray ink)
+ # would otherwise be classified too, which is rarely useful.
+ working = ingest_glyphs_to_classify(test_page, test_json)
+ print(f"Ingested {len(working)} test glyphs from {test_page.name}")
+
+ classified, _ = run_correction_stage(working, training)
+
+ counts = Counter(g.class_name for g in classified)
+ print(f"Classified into {len(counts)} distinct classes:")
+ for name, n in counts.most_common():
+ print(f" {n:4d} {name}")
+
+ annotated_out = output_dir / f"{test_page.stem}_annotated.png"
+ predicted_out = output_dir / f"{test_page.stem}_predicted.png"
+ draw_annotation_overlay(
+ image=test_page, annotations=test_json, output=annotated_out
+ )
+ draw_prediction_overlay(
+ image=test_page,
+ annotations=test_json,
+ output=predicted_out,
+ classified=classified,
+ )
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--train-xml", type=Path, default=DEFAULT_TRAIN_XML)
+ parser.add_argument("--test-page", type=Path, default=DEFAULT_TEST_PAGE)
+ parser.add_argument("--test-json", type=Path, default=DEFAULT_TEST_JSON)
+ parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
+ args = parser.parse_args()
+
+ run(args.train_xml, args.test_page, args.test_json, args.output_dir)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/core/tests/sample_input/helpers/visualize.py b/core/tests/sample_input/helpers/visualize.py
new file mode 100644
index 0000000..45f54b3
--- /dev/null
+++ b/core/tests/sample_input/helpers/visualize.py
@@ -0,0 +1,185 @@
+import colorsys
+import hashlib
+import json
+from pathlib import Path
+
+from PIL import Image, ImageDraw, ImageFont
+
+HERE = Path(__file__).parent
+SAMPLE_DIR = HERE.parent
+IMAGE = SAMPLE_DIR / "NZ-Wt MSR-03 109v.png"
+ANNOTATIONS = SAMPLE_DIR / "MOTHRA_NZ-Wt MSR-03 109v_annotations.json"
+OUTPUT = SAMPLE_DIR / "visualization" / "NZ-Wt MSR-03 109v_annotated.png"
+PREDICTED_OUTPUT = SAMPLE_DIR / "visualization" / "NZ-Wt MSR-03 109v_predicted.png"
+# IMAGE = SAMPLE_DIR / "Hufnagel-example.png"
+# ANNOTATIONS = SAMPLE_DIR / "Hufnagel-example_annotations.json"
+# OUTPUT = SAMPLE_DIR / "visualization" / "Hufnagel-example_annotated.png"
+# PREDICTED_OUTPUT = SAMPLE_DIR / "visualization" / "Hufnagel-example_predicted.png"
+
+CLASS_COLORS = {1: "#e6194B", 2: "#3cb44b", 3: "#4363d8"}
+FALLBACK_COLOR = "#f032e6"
+
+#: Non-glyph MOTHRA classes (staff lines, stray ink). Drawn dimmed
+#: on the predicted-class overlay so the eye stays on the boxes the
+#: classifier actually scored.
+NON_GLYPH_CLASS_IDS = {1, 3}
+NON_GLYPH_DIM_COLOR = "#bbbbbb"
+
+AXIS_COLOR = "white"
+AXIS_TEXT_STROKE = "white"
+MAJOR_TICK = 100
+MINOR_TICK = 50
+
+
+def color_for_class(class_name: str) -> str:
+ """Pick a stable colour for a predicted class label.
+
+ The hue is hashed off the label so the same class always gets the
+ same colour across runs (no ad-hoc palette to maintain). Saturation
+ and value are fixed so every colour stays readable on the parchment
+ background.
+ """
+ digest = hashlib.md5(class_name.encode("utf-8")).digest()
+ hue = (int.from_bytes(digest[:2], "big") % 360) / 360.0
+ r, g, b = colorsys.hsv_to_rgb(hue, 0.85, 0.85)
+ return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
+
+
+def draw_coordinate_scheme(draw: ImageDraw.ImageDraw, width: int, height: int) -> None:
+ font = ImageFont.load_default()
+
+ def label(xy, text):
+ draw.text(
+ xy, text, fill=AXIS_COLOR, font=font,
+ stroke_width=0.2, stroke_fill=AXIS_TEXT_STROKE,
+ )
+
+ # Top edge: x-axis ticks
+ for x in range(0, width + 1, MINOR_TICK):
+ major = x % MAJOR_TICK == 0
+ draw.line([(x, 0), (x, 10 if major else 5)], fill=AXIS_COLOR, width=1)
+ if major and x > 0:
+ label((x + 2, 12), str(x))
+
+ # Left edge: y-axis ticks
+ for y in range(0, height + 1, MINOR_TICK):
+ major = y % MAJOR_TICK == 0
+ draw.line([(0, y), (10 if major else 5, y)], fill=AXIS_COLOR, width=1)
+ if major and y > 0:
+ label((12, y + 2), str(y))
+
+ # Origin marker
+ label((4, 4), "(0,0)")
+
+ # Scale bar in the lower-right corner: 100 px
+ bar_len = MAJOR_TICK
+ bar_y = height - 30
+ bar_x_end = width - 20
+ bar_x_start = bar_x_end - bar_len
+ draw.line([(bar_x_start, bar_y), (bar_x_end, bar_y)], fill=AXIS_COLOR, width=3)
+ draw.line([(bar_x_start, bar_y - 5), (bar_x_start, bar_y + 5)], fill=AXIS_COLOR, width=2)
+ draw.line([(bar_x_end, bar_y - 5), (bar_x_end, bar_y + 5)], fill=AXIS_COLOR, width=2)
+ label((bar_x_start, bar_y + 6), f"{bar_len} px")
+
+
+def draw_annotation_overlay(
+ image: Path = IMAGE,
+ annotations: Path = ANNOTATIONS,
+ output: Path = OUTPUT,
+) -> None:
+ """Write ``…_annotated.png``: MOTHRA classId=2 boxes only, in green."""
+ data = json.loads(annotations.read_text())
+ img = Image.open(image).convert("RGB")
+ draw = ImageDraw.Draw(img)
+ for ann in data["annotations"]:
+ x, y, w, h = ann["bbox"]
+ if ann["classId"] == 2:
+ color = CLASS_COLORS.get(ann["classId"], FALLBACK_COLOR)
+ draw.rectangle([x, y, x + w, y + h], outline=color, width=2)
+ draw_coordinate_scheme(draw, img.width, img.height)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ img.save(output)
+ print(f"wrote {output}")
+
+
+def draw_prediction_overlay(
+ image: Path = IMAGE,
+ annotations: Path = ANNOTATIONS,
+ output: Path = PREDICTED_OUTPUT,
+ classified=None,
+) -> None:
+ """Write ``…_predicted.png``: classId=2 boxes coloured by predicted class.
+
+ Non-glyph boxes (classId 1 and 3) are drawn dimmed for context.
+
+ If ``classified`` is None, the function calls
+ :func:`evaluate.classify_page` itself (using the same ``image`` and
+ ``annotations`` paths) — convenient for the standalone CLI. Pass a
+ pre-computed glyph list when a caller (e.g. ``run_pipeline.py``)
+ has already run classification and wants to avoid the double-work.
+ """
+ if classified is None:
+ # Import lazily so the annotation overlay still works in
+ # environments where ic_core isn't installed.
+ from evaluate import classify_page # type: ignore[import-not-found]
+
+ classified, _ = classify_page(page_path=image, json_path=annotations)
+
+ data = json.loads(annotations.read_text())
+
+ # Index predictions by glyph UUID. ingest_page preserves the
+ # MOTHRA annotation `id` (minus dashes) as the Glyph UUID, so we
+ # can match each prediction back to its source annotation.
+ import uuid
+
+ predicted_by_id: dict[str, tuple[str, float]] = {
+ g.id: (g.class_name, g.confidence) for g in classified
+ }
+
+ img = Image.open(image).convert("RGB")
+ draw = ImageDraw.Draw(img)
+ font = ImageFont.load_default()
+
+ for ann in data["annotations"]:
+ x, y, w, h = ann["bbox"]
+ if ann["classId"] in NON_GLYPH_CLASS_IDS:
+ draw.rectangle(
+ [x, y, x + w, y + h], outline=NON_GLYPH_DIM_COLOR, width=1
+ )
+ continue
+
+ key = uuid.UUID(ann["id"]).hex
+ match = predicted_by_id.get(key)
+ if match is None:
+ # Shouldn't happen — every classId=2 box should be
+ # ingested — but draw something legible if it does.
+ draw.rectangle([x, y, x + w, y + h], outline=FALLBACK_COLOR, width=2)
+ continue
+
+ class_name, confidence = match
+ color = color_for_class(class_name)
+ draw.rectangle([x, y, x + w, y + h], outline=color, width=2)
+ # Label above the box: class name + confidence to two places.
+ label = f"{class_name} {confidence:.2f}"
+ draw.text(
+ (x, max(0, y - 12)),
+ label,
+ fill=color,
+ font=font,
+ stroke_width=2,
+ stroke_fill="white",
+ )
+
+ draw_coordinate_scheme(draw, img.width, img.height)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ img.save(output)
+ print(f"wrote {output}")
+
+
+def main():
+ draw_annotation_overlay()
+ draw_prediction_overlay()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/core/tests/sample_input/visualization/Hufnagel-example_annotated.png b/core/tests/sample_input/visualization/Hufnagel-example_annotated.png
new file mode 100644
index 0000000..20b73b0
Binary files /dev/null and b/core/tests/sample_input/visualization/Hufnagel-example_annotated.png differ
diff --git a/core/tests/sample_input/visualization/Hufnagel-example_predicted.png b/core/tests/sample_input/visualization/Hufnagel-example_predicted.png
new file mode 100644
index 0000000..7957880
Binary files /dev/null and b/core/tests/sample_input/visualization/Hufnagel-example_predicted.png differ
diff --git a/core/tests/sample_input/visualization/NZ-Wt MSR-03 109v_annotated.png b/core/tests/sample_input/visualization/NZ-Wt MSR-03 109v_annotated.png
new file mode 100644
index 0000000..c5d4ee7
Binary files /dev/null and b/core/tests/sample_input/visualization/NZ-Wt MSR-03 109v_annotated.png differ
diff --git a/core/tests/sample_input/visualization/NZ-Wt MSR-03 109v_predicted.png b/core/tests/sample_input/visualization/NZ-Wt MSR-03 109v_predicted.png
new file mode 100644
index 0000000..6d9ffca
Binary files /dev/null and b/core/tests/sample_input/visualization/NZ-Wt MSR-03 109v_predicted.png differ
diff --git a/core/tests/test_real_input_knn.py b/core/tests/test_real_input_knn.py
new file mode 100644
index 0000000..9df4ba5
--- /dev/null
+++ b/core/tests/test_real_input_knn.py
@@ -0,0 +1,252 @@
+"""End-to-end KNN tests against the real sample manuscript page.
+
+Four scenarios:
+
+* **smoke** — ingest the MOTHRA page (filtered to ``classId == 2``),
+ train on the legacy GameraXML database, run a full correction
+ stage, and assert basic invariants on the result (no crash, all
+ glyphs accounted for, confidences in range, no class collapse,
+ predicted labels live in the known vocabulary).
+* **determinism** — run the smoke pipeline twice and assert the
+ predictions match byte-for-byte.
+* **5-fold accuracy** (``-m slow``) — stratified 5-fold cross-validation
+ over the GameraXML database. MOTHRA doesn't carry per-glyph neume
+ labels, so accuracy has to be measured on the only data source
+ that does. Marked slow because it retrains 5 times.
+* **LOO accuracy** (env-gated) — leave-one-out on a stratified
+ subset of the database. ``IC_RUN_LOO=1`` to enable; the subset
+ size defaults to 50 glyphs and is overridable via
+ ``IC_LOO_LIMIT``.
+
+cd core/ic_core && uv run pytest ../tests/test_real_input_knn.py -v
+IC_RUN_LOO=1 IC_LOO_LIMIT=200 uv run pytest ../tests/test_real_input_knn.py::test_xml_db_loo_accuracy -v -s
+uv run python ../tests/sample_input/helpers/visualize.py
+
+"""
+from __future__ import annotations
+
+import csv
+import os
+import random
+from collections import Counter, defaultdict
+from pathlib import Path
+
+import pytest
+
+from ic_core.classifier import InteractiveClassifier
+from ic_core.glyph import Glyph
+from ic_core.io_xml import load_glyphs
+from sample_input.helpers.evaluate import (
+ TRAINING_XML_PATH,
+ classify_page,
+ ingest_glyphs_to_classify,
+)
+
+CSV_VOCAB_PATH = (
+ Path(__file__).parent / "sample_input" / "csv-square_notation_neume_level_newest.csv"
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def training_db() -> list[Glyph]:
+ """The legacy GameraXML training database, loaded once per module."""
+ return load_glyphs(TRAINING_XML_PATH)
+
+
+@pytest.fixture(scope="module")
+def page_glyphs() -> list[Glyph]:
+ """MOTHRA page glyphs filtered to ``classId == 2``, loaded once per module."""
+ return ingest_glyphs_to_classify()
+
+
+@pytest.fixture(scope="module")
+def vocab(training_db) -> set[str]:
+ """Union of class labels from the CSV vocab + the GameraXML training DB.
+
+ Predictions can only emit labels seen during training, so the
+ DB classes alone would suffice for the assertion — but checking
+ against the CSV too catches cases where the training DB drifts
+ away from the canonical neume vocabulary.
+ """
+ db_labels = {g.class_name for g in training_db}
+ csv_labels: set[str] = set()
+ with open(CSV_VOCAB_PATH, newline="") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ label = (row.get("classification") or "").strip()
+ if label:
+ csv_labels.add(label)
+ return db_labels | csv_labels
+
+
+# ---------------------------------------------------------------------------
+# 1. Smoke — real page through the full pipeline
+# ---------------------------------------------------------------------------
+
+
+def test_real_page_smoke(page_glyphs, training_db, vocab):
+ classified, classifier = classify_page()
+
+ # Every input glyph is returned (none dropped).
+ assert len(classified) == len(page_glyphs)
+
+ # UUIDs preserved across the round trip (algorithm invariant #6).
+ assert {g.id for g in classified} == {g.id for g in page_glyphs}
+
+ # Every glyph got a real prediction — none left UNCLASSIFIED.
+ assert all(g.class_name != "UNCLASSIFIED" for g in classified)
+
+ # Confidence sits strictly inside the documented (0, 1] interval.
+ assert all(0.0 < g.confidence <= 1.0 for g in classified)
+
+ # No class collapse: the classifier didn't assign every glyph the
+ # same label. (44/86/6 is the classId split, but inside classId=2
+ # we expect punctum/clivis/etc. — at least 2 distinct labels.)
+ predicted = {g.class_name for g in classified}
+ assert len(predicted) >= 2
+
+ # Every predicted label lives in the known vocabulary.
+ assert predicted.issubset(vocab), (
+ f"Unexpected labels outside vocabulary: {predicted - vocab}"
+ )
+
+ # Classifier reports it was trained on the assembled pool.
+ assert classifier.is_trained
+ assert classifier.training_size > 0
+
+ # Predicted-class histogram — printed under `-v` for triaging a
+ # regression (e.g. "punctum predictions dropped from 60 to 4
+ # between commits"). Piggybacks on the smoke run so we don't
+ # re-ingest+classify just to log counts.
+ counts = Counter(g.class_name for g in classified)
+ print("\nPredicted-class histogram:")
+ for name, n in counts.most_common():
+ print(f" {n:4d} {name}")
+
+
+# ---------------------------------------------------------------------------
+# 2. Determinism — two runs produce identical predictions
+# ---------------------------------------------------------------------------
+
+
+def test_real_page_determinism():
+ a, _ = classify_page()
+ b, _ = classify_page()
+
+ a_by_id = {g.id: (g.class_name, g.confidence) for g in a}
+ b_by_id = {g.id: (g.class_name, g.confidence) for g in b}
+ assert a_by_id == b_by_id
+
+
+# ---------------------------------------------------------------------------
+# 3. Stratified 5-fold accuracy on the GameraXML training DB
+# ---------------------------------------------------------------------------
+
+
+def _stratified_folds(
+ glyphs: list[Glyph], n_splits: int, seed: int = 0
+) -> list[list[int]]:
+ """Assign each glyph an index in [0, n_splits) round-robin per class.
+
+ Classes with fewer than ``n_splits`` members are dropped — they
+ can't be stratified across folds without leaving some empty.
+ Returns a list of fold-index lists; ``folds[i]`` is the list of
+ glyph indices assigned to fold ``i``.
+ """
+ by_class: dict[str, list[int]] = defaultdict(list)
+ for i, g in enumerate(glyphs):
+ by_class[g.class_name].append(i)
+
+ rng = random.Random(seed)
+ folds: list[list[int]] = [[] for _ in range(n_splits)]
+ for cls, indices in by_class.items():
+ if len(indices) < n_splits:
+ continue # excluded — too rare to stratify
+ shuffled = indices[:]
+ rng.shuffle(shuffled)
+ for offset, idx in enumerate(shuffled):
+ folds[offset % n_splits].append(idx)
+ return folds
+
+
+@pytest.mark.slow
+def test_xml_db_5fold_accuracy(training_db):
+ n_splits = 5
+ folds = _stratified_folds(training_db, n_splits)
+
+ # Total covered glyphs (excluding tail classes with < n_splits).
+ total = sum(len(f) for f in folds)
+ assert total > 0, "stratifier dropped all glyphs — fixture is too small"
+
+ correct = 0
+ seen = 0
+ for i in range(n_splits):
+ test_idx = folds[i]
+ train_idx = [j for k in range(n_splits) if k != i for j in folds[k]]
+ train = [training_db[j] for j in train_idx]
+ test = [training_db[j] for j in test_idx]
+
+ clf = InteractiveClassifier(k=1).fit(train)
+ preds = clf.predict_many(test)
+ for g, p in zip(test, preds):
+ seen += 1
+ if p.class_name == g.class_name:
+ correct += 1
+
+ accuracy = correct / seen
+ print(
+ f"\n[5-fold] tested {seen} glyphs across {n_splits} folds, "
+ f"accuracy={accuracy:.4f}"
+ )
+ # Floor calibrated against the first green run (~0.95) with a
+ # 10-point margin for jitter from the shuffle seed. A real
+ # regression will drop accuracy well below this band.
+ assert accuracy >= 0.85, f"k=1 5-fold accuracy below 0.85: {accuracy:.4f}"
+
+
+# ---------------------------------------------------------------------------
+# 4. LOO accuracy on a stratified subset (opt-in via env)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.skipif(
+ os.environ.get("IC_RUN_LOO") != "1",
+ reason="LOO is opt-in: set IC_RUN_LOO=1 to run",
+)
+def test_xml_db_loo_accuracy(training_db):
+ # Cap the subset so the test stays runnable: full N=2221 means
+ # 2221 refits, which is slow even with cached features. Default
+ # 50 is enough to catch a regression; bump via IC_LOO_LIMIT.
+ limit = int(os.environ.get("IC_LOO_LIMIT", "50"))
+
+ by_class: dict[str, list[Glyph]] = defaultdict(list)
+ for g in training_db:
+ by_class[g.class_name].append(g)
+ # Drop singletons — leaving one out would leave the class with no
+ # representative and the prediction can't possibly be correct.
+ eligible = [g for cls, gs in by_class.items() if len(gs) >= 2 for g in gs]
+
+ rng = random.Random(0)
+ rng.shuffle(eligible)
+ subset = eligible[:limit]
+ assert subset
+
+ # Index the full DB by id for O(1) "everyone except this glyph" lookup.
+ correct = 0
+ for held_out in subset:
+ train = [g for g in training_db if g.id != held_out.id]
+ clf = InteractiveClassifier(k=1).fit(train)
+ pred = clf.predict(held_out)
+ if pred.class_name == held_out.class_name:
+ correct += 1
+
+ accuracy = correct / len(subset)
+ print(f"\n[LOO] tested {len(subset)} glyphs, accuracy={accuracy:.4f}")
+ assert accuracy >= 0.70, f"k=1 LOO accuracy below 0.70: {accuracy:.4f}"
+
+
diff --git a/docs/image.png b/docs/image.png
new file mode 100644
index 0000000..f33c4f6
Binary files /dev/null and b/docs/image.png differ
diff --git a/docs/square_neume_mapping.numbers b/docs/square_neume_mapping.numbers
new file mode 100644
index 0000000..e04a7cb
Binary files /dev/null and b/docs/square_neume_mapping.numbers differ