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