From 62a75ebc4699682e59833687f1f5f9db135c1c69 Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Sat, 1 Mar 2025 16:16:53 -0500 Subject: [PATCH 01/11] Added job for single staff finding --- .../rodan/jobs/one_staff_finding/__init__.py | 8 + .../one_staff_finding/one_staff_finding.py | 196 ++++++++++++++++++ .../one_staff_finding/resource_types.yaml | 12 ++ rodan-main/code/rodan/registerJobs.yaml | 4 + 4 files changed, 220 insertions(+) create mode 100644 rodan-main/code/rodan/jobs/one_staff_finding/__init__.py create mode 100644 rodan-main/code/rodan/jobs/one_staff_finding/one_staff_finding.py create mode 100644 rodan-main/code/rodan/jobs/one_staff_finding/resource_types.yaml diff --git a/rodan-main/code/rodan/jobs/one_staff_finding/__init__.py b/rodan-main/code/rodan/jobs/one_staff_finding/__init__.py new file mode 100644 index 000000000..a9ebc747b --- /dev/null +++ b/rodan-main/code/rodan/jobs/one_staff_finding/__init__.py @@ -0,0 +1,8 @@ +import logging + +import rodan +from rodan.jobs import module_loader + +__version__ = "0.0.2" +logger = logging.getLogger("rodan") +module_loader("rodan.jobs.one_staff_finding.one_staff_finding") diff --git a/rodan-main/code/rodan/jobs/one_staff_finding/one_staff_finding.py b/rodan-main/code/rodan/jobs/one_staff_finding/one_staff_finding.py new file mode 100644 index 000000000..69727dbb0 --- /dev/null +++ b/rodan-main/code/rodan/jobs/one_staff_finding/one_staff_finding.py @@ -0,0 +1,196 @@ +from rodan.jobs.base import RodanTask +import cv2 +import numpy as np +import math +from PIL import Image +import json +import json.encoder + +def dist(pt1, pt2): + return math.sqrt(((pt1[0] - pt2[0])**2) + ((pt1[1] - pt2[1])**2)) + +def coords(x1, y1, x2, y2, new_x): + slope = (y2 - y1) / (x2 - x1) + b = (-1 * (slope * x1)) + y1 + return (slope * new_x) + b + +def padding(sect): + #add extra space around the line segments + old_h, old_w, c = sect.shape + new_h = old_h + 100 + new_w = old_w + 100 + result = np.full((new_h, new_w, c), (255, 255, 255), dtype=np.uint8) + x_center = (new_w - old_w) // 2 + y_center = (new_h - old_h) // 2 + result[y_center:y_center+old_h, x_center:x_center+old_w] = sect + + #bounding rectangle preprocessing + gray = cv2.cvtColor(result, cv2.COLOR_BGR2GRAY) + blur = cv2.GaussianBlur(gray, (1, 1), 0) + thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] + kernal = cv2.getStructuringElement(cv2.MORPH_RECT, (8, 1)) + dilate = cv2.dilate(thresh, kernal, iterations=7) + conts = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + conts = conts[0] if len(conts) == 2 else conts[1] + conts = sorted(conts, key=lambda x: cv2.boundingRect(x)[0]) + + ret = [] + + for c in conts: + rect = cv2.minAreaRect(c) + box = cv2.boxPoints(rect) + box = np.int0(box) + result = cv2.drawContours(result,[box],0,(0,255,0),2) + origin = box[0] + new_pts = sorted(box, key=lambda x: dist(x, origin)) + pt0 = new_pts[0] + pt1 = new_pts[1] + pt2 = new_pts[2] + pt3 = new_pts[3] + + mid1 = [int((pt0[0] + pt1[0]) / 2) - 50, int((pt0[1] + pt1[1]) / 2) - 50] + mid2 = [int((pt2[0] + pt3[0]) / 2) - 50, int((pt2[1] + pt3[1]) / 2) - 50] + ret = [mid1, mid2] + + return ret + +def to_json(img, data): + h, w, _ = img.shape + staves = [] + for i in range(0, len(data)): + cur = data[i] + box = cur[0] + x, y, width, height = box + lines = cur[1] + staves.append({ + "staff_no": i, + "bounding_box":{ + "ncols": width, + "nrows": height, + "ulx": x, + "uly": y + }, + "num_lines": 1, + "line_positions": lines + }) + return { + "page":{ + "resolution": 0.0, + "bounding_box":{ + "ncols": w, + "nrows": h, + "ulx": 0, + "uly": 0 + }, + "staves": staves + } + } + +class OneStaffFinding(RodanTask): + name = "One Staff Finding" + author = "Deanna Chun" + description = "Trace single Aquitanian staff lines" + settings = { + 'title': 'Settings', + 'type': 'object', + 'job_queue': 'Python3', + 'required': ['Slices'], + 'properties': { + 'Slices': { + 'type': 'integer', + 'default': 8, + 'minimum': 1, + 'maximum': 24, + 'description': 'Number of divisions per single staff line' + } + } + } + enabled = True + category = "Staff Detection" + interactive = False + input_port_types = [{ + 'name': 'Image containing staves (RGB, greyscale, or onebit)', + 'resource_types': ['image/rgb+png', 'image/onebit+png', 'image/greyscale+png'], + 'minimum': 1, + 'maximum': 1, + 'is_list': False + }] + + output_port_types = [{ + 'name': 'JSOMR', + 'resource_types': ['application/json'], + 'minimum': 1, + 'maximum': 1, + 'is_list': False + }, + { + 'name': 'Overlayed Lines', + 'resource_types': ['image/rgb+png'], + 'minimum': 0, + 'maximum': 1 + }] + + def run_my_task(self, inputs, settings, outputs): + input_path = inputs["Image containing staves (RGB, greyscale, or onebit)"][0]["resource_path"] + overlay = "Overlayed Lines" in outputs + slices = settings['Slices'] + + img = cv2.imread(input_path) + + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + blur = cv2.GaussianBlur(gray, (1, 1), 0) + thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] + kernal = cv2.getStructuringElement(cv2.MORPH_RECT, (8, 1)) + dilate = cv2.dilate(thresh, kernal, iterations=7) + conts = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + conts = conts[0] if len(conts) == 2 else conts[1] + conts = sorted(conts, key=lambda x: cv2.boundingRect(x)[0]) + + ret = [] + + for c in conts: + x, y, w, h = cv2.boundingRect(c) + part = w // slices + last = [] + lines = [] + for i in range(0, slices): + img_sect = img[y:y+h, x+(part*i):x+(part*(i+1))] + line = padding(img_sect) + if line != []: + line[0][0] += x+(part*i) + line[1][0] += x+(part*i) + line[0][1] += y + line[1][1] += y + + #normalize lines to bounds + new_y1 = int(coords(line[0][0], line[0][1], line[1][0], line[1][1], x+(part*i))) + new_y2 = int(coords(line[0][0], line[0][1], line[1][0], line[1][1], x+(part*(i+1)))) + line[0] = [x+(part*i), new_y1] + line[1] = [x+(part*(i+1)), new_y2] + + #make sure lines connect together + if last != []: + line[0] = last + last = line[1] + lines.append(line[0]) + + #draw line + if overlay: + cv2.line(img, tuple(line[0]), tuple(line[1]), (255, 0, 0), 2) + ret.append(([x, y, w, h], lines)) + + jsomr = to_json(img, ret) + + outfile_path = outputs['JSOMR'][0]['resource_path'] + with open(outfile_path, "w") as outfile: + outfile.write(json.dumps(jsomr)) + + if overlay: + outfile_path2 = outputs["Overlayed Lines"][0]["resource_path"] + #img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB) + overlay_save = Image.fromarray(img) + overlay_save.save(outfile_path2, 'PNG') + + return True \ No newline at end of file diff --git a/rodan-main/code/rodan/jobs/one_staff_finding/resource_types.yaml b/rodan-main/code/rodan/jobs/one_staff_finding/resource_types.yaml new file mode 100644 index 000000000..277698da9 --- /dev/null +++ b/rodan-main/code/rodan/jobs/one_staff_finding/resource_types.yaml @@ -0,0 +1,12 @@ +- mimetype: image/rgb+png + description: RGB PNG image + extension: png +- mimetype: image/onebit+png + description: One-bit (black and white) PNG image + extension: png +- mimetype: image/greyscale+png + description: Greyscale PNG image + extension: png +- mimetype: application/json + description: JSON + extension: json \ No newline at end of file diff --git a/rodan-main/code/rodan/registerJobs.yaml b/rodan-main/code/rodan/registerJobs.yaml index df418c0d1..a7bc32d3f 100644 --- a/rodan-main/code/rodan/registerJobs.yaml +++ b/rodan-main/code/rodan/registerJobs.yaml @@ -5,6 +5,10 @@ "rodan.jobs.labeler" : [ "Labeler" ] } "RODAN_PYTHON3_JOBS": { + #new job + "rodan.jobs.one_staff_finding":{ + "rodan.jobs.one_staff_finding.one_staff_finding": ["OneStaffFinding"] + }, "rodan.jobs.helloworld": { "rodan.jobs.helloworld.helloworld" : ["HelloWorld"], "rodan.jobs.helloworld.helloworld" : ["HelloWorldMultiPort"], From 0461aab0cc8f57bde10b9175c5a7efa0e3dd5de5 Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Thu, 6 Mar 2025 05:46:32 -0500 Subject: [PATCH 02/11] Added if case for 1 staff to Miyao Staff Finding --- .../jobs/heuristic_pitch_finding/base.py | 146 +++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/rodan-main/code/rodan/jobs/heuristic_pitch_finding/base.py b/rodan-main/code/rodan/jobs/heuristic_pitch_finding/base.py index b40562f5f..4a1da45cd 100644 --- a/rodan-main/code/rodan/jobs/heuristic_pitch_finding/base.py +++ b/rodan-main/code/rodan/jobs/heuristic_pitch_finding/base.py @@ -12,8 +12,91 @@ import json import json.encoder +import cv2 +import numpy as np +import math +from PIL import Image + +def dist(pt1, pt2): + return math.sqrt(((pt1[0] - pt2[0])**2) + ((pt1[1] - pt2[1])**2)) + +def coords(x1, y1, x2, y2, new_x): + slope = (y2 - y1) / (x2 - x1) + b = (-1 * (slope * x1)) + y1 + return (slope * new_x) + b + +def to_json(img, data): + h, w, _ = img.shape + staves = [] + for i in range(0, len(data)): + cur = data[i] + box = cur[0] + x, y, width, height = box + lines = cur[1] + staves.append({ + "staff_no": i, + "bounding_box":{ + "ncols": width, + "nrows": height, + "ulx": x, + "uly": y + }, + "num_lines": 1, + "line_positions": lines + }) + return { + "page":{ + "resolution": 0.0, + "bounding_box":{ + "ncols": w, + "nrows": h, + "ulx": 0, + "uly": 0 + }, + "staves": staves + } + } - +def padding(sect): + #add extra space around the line segments + old_h, old_w, c = sect.shape + new_h = old_h + 100 + new_w = old_w + 100 + result = np.full((new_h, new_w, c), (255, 255, 255), dtype=np.uint8) + x_center = (new_w - old_w) // 2 + y_center = (new_h - old_h) // 2 + result[y_center:y_center+old_h, x_center:x_center+old_w] = sect + + #bounding rectangle preprocessing + gray = cv2.cvtColor(result, cv2.COLOR_BGR2GRAY) + blur = cv2.GaussianBlur(gray, (1, 1), 0) + thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] + kernal = cv2.getStructuringElement(cv2.MORPH_RECT, (8, 1)) + dilate = cv2.dilate(thresh, kernal, iterations=7) + conts = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + conts = conts[0] if len(conts) == 2 else conts[1] + conts = sorted(conts, key=lambda x: cv2.boundingRect(x)[0]) + + ret = [] + + for c in conts: + rect = cv2.minAreaRect(c) + box = cv2.boxPoints(rect) + box = np.int0(box) + result = cv2.drawContours(result,[box],0,(0,255,0),2) + origin = box[0] + new_pts = sorted(box, key=lambda x: dist(x, origin)) + pt0 = new_pts[0] + pt1 = new_pts[1] + pt2 = new_pts[2] + pt3 = new_pts[3] + + mid1 = [int((pt0[0] + pt1[0]) / 2) - 50, int((pt0[1] + pt1[1]) / 2) - 50] + mid2 = [int((pt2[0] + pt3[0]) / 2) - 50, int((pt2[1] + pt3[1]) / 2) - 50] + ret = [mid1, mid2] + + return ret class MiyaoStaffinding(RodanTask): name = 'Miyao Staff Finding' @@ -40,6 +123,13 @@ class MiyaoStaffinding(RodanTask): 'type': 'boolean', 'default': True, 'description': 'Interpolate found line points so all lines have the same number of points. This MUST be True for pitch finding to succeed.' + }, + 'Slices': { + 'type': 'integer', + 'default': 8, + 'minimum': 1, + 'maximum': 24, + 'description': '(For One Staff Finding) Number of divisions per staff line' } } } @@ -61,6 +151,60 @@ class MiyaoStaffinding(RodanTask): }] def run_my_task(self, inputs, settings, outputs): + + #Branch for one staff finding + if settings['Number of lines'] == 1: + image = cv2.imread(inputs['Image containing staves (RGB, greyscale, or onebit)'][0]['resource_path']) + slices = settings['Slices'] + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + blur = cv2.GaussianBlur(gray, (1, 1), 0) + thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] + kernal = cv2.getStructuringElement(cv2.MORPH_RECT, (8, 1)) + dilate = cv2.dilate(thresh, kernal, iterations=7) + conts = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + conts = conts[0] if len(conts) == 2 else conts[1] + conts = sorted(conts, key=lambda x: cv2.boundingRect(x)[0]) + + ret = [] + + for c in conts: + x, y, w, h = cv2.boundingRect(c) + part = w // slices + last = [] + lines = [] + for i in range(0, slices): + img_sect = image[y:y+h, x+(part*i):x+(part*(i+1))] + line = padding(img_sect) + if line != []: + line[0][0] += x+(part*i) + line[1][0] += x+(part*i) + line[0][1] += y + line[1][1] += y + + #normalize lines to bounds + new_y1 = int(coords(line[0][0], line[0][1], line[1][0], line[1][1], x+(part*i))) + new_y2 = int(coords(line[0][0], line[0][1], line[1][0], line[1][1], x+(part*(i+1)))) + line[0] = [x+(part*i), new_y1] + line[1] = [x+(part*(i+1)), new_y2] + + #make sure lines connect together + if last != []: + line[0] = last + last = line[1] + lines.append(line[0]) + + #draw line + + ret.append(([x, y, w, h], lines)) + + jsomr = to_json(image, ret) + + outfile_path = outputs['JSOMR'][0]['resource_path'] + with open(outfile_path, "w") as outfile: + outfile.write(json.dumps(jsomr)) + return True + # Inputs image = load_image(inputs['Image containing staves (RGB, greyscale, or onebit)'][0]['resource_path']) From 99827cdfd70a246fa217262ee0165256feca7bdf Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Tue, 25 Mar 2025 17:11:05 -0400 Subject: [PATCH 03/11] Removed branch for 1 line from Miyao Staff Finding, renamed One Staff Finding to Aquitanian Reference Line Finding --- .../__init__.py | 2 +- .../aquitanian_ref_line_finding.py} | 8 +- .../resource_types.yaml | 0 .../jobs/heuristic_pitch_finding/base.py | 146 +----------------- rodan-main/code/rodan/registerJobs.yaml | 5 +- 5 files changed, 8 insertions(+), 153 deletions(-) rename rodan-main/code/rodan/jobs/{one_staff_finding => aquitanian_ref_line_finding}/__init__.py (57%) rename rodan-main/code/rodan/jobs/{one_staff_finding/one_staff_finding.py => aquitanian_ref_line_finding/aquitanian_ref_line_finding.py} (93%) rename rodan-main/code/rodan/jobs/{one_staff_finding => aquitanian_ref_line_finding}/resource_types.yaml (100%) diff --git a/rodan-main/code/rodan/jobs/one_staff_finding/__init__.py b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/__init__.py similarity index 57% rename from rodan-main/code/rodan/jobs/one_staff_finding/__init__.py rename to rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/__init__.py index a9ebc747b..ae9dfbfb8 100644 --- a/rodan-main/code/rodan/jobs/one_staff_finding/__init__.py +++ b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/__init__.py @@ -5,4 +5,4 @@ __version__ = "0.0.2" logger = logging.getLogger("rodan") -module_loader("rodan.jobs.one_staff_finding.one_staff_finding") +module_loader("rodan.jobs.aquitanian_ref_line_finding.aquitanian_ref_line_finding") diff --git a/rodan-main/code/rodan/jobs/one_staff_finding/one_staff_finding.py b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py similarity index 93% rename from rodan-main/code/rodan/jobs/one_staff_finding/one_staff_finding.py rename to rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py index 69727dbb0..0d16e4bd4 100644 --- a/rodan-main/code/rodan/jobs/one_staff_finding/one_staff_finding.py +++ b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py @@ -87,10 +87,10 @@ def to_json(img, data): } } -class OneStaffFinding(RodanTask): - name = "One Staff Finding" +class AquitanianReferenceLineFinding(RodanTask): + name = "Aquitanian Reference Line Finding" author = "Deanna Chun" - description = "Trace single Aquitanian staff lines" + description = "Trace single Aquitanian reference lines" settings = { 'title': 'Settings', 'type': 'object', @@ -102,7 +102,7 @@ class OneStaffFinding(RodanTask): 'default': 8, 'minimum': 1, 'maximum': 24, - 'description': 'Number of divisions per single staff line' + 'description': 'Number of divisions per single reference line' } } } diff --git a/rodan-main/code/rodan/jobs/one_staff_finding/resource_types.yaml b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/resource_types.yaml similarity index 100% rename from rodan-main/code/rodan/jobs/one_staff_finding/resource_types.yaml rename to rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/resource_types.yaml diff --git a/rodan-main/code/rodan/jobs/heuristic_pitch_finding/base.py b/rodan-main/code/rodan/jobs/heuristic_pitch_finding/base.py index 4a1da45cd..b40562f5f 100644 --- a/rodan-main/code/rodan/jobs/heuristic_pitch_finding/base.py +++ b/rodan-main/code/rodan/jobs/heuristic_pitch_finding/base.py @@ -12,91 +12,8 @@ import json import json.encoder -import cv2 -import numpy as np -import math -from PIL import Image - -def dist(pt1, pt2): - return math.sqrt(((pt1[0] - pt2[0])**2) + ((pt1[1] - pt2[1])**2)) - -def coords(x1, y1, x2, y2, new_x): - slope = (y2 - y1) / (x2 - x1) - b = (-1 * (slope * x1)) + y1 - return (slope * new_x) + b - -def to_json(img, data): - h, w, _ = img.shape - staves = [] - for i in range(0, len(data)): - cur = data[i] - box = cur[0] - x, y, width, height = box - lines = cur[1] - staves.append({ - "staff_no": i, - "bounding_box":{ - "ncols": width, - "nrows": height, - "ulx": x, - "uly": y - }, - "num_lines": 1, - "line_positions": lines - }) - return { - "page":{ - "resolution": 0.0, - "bounding_box":{ - "ncols": w, - "nrows": h, - "ulx": 0, - "uly": 0 - }, - "staves": staves - } - } -def padding(sect): - #add extra space around the line segments - old_h, old_w, c = sect.shape - new_h = old_h + 100 - new_w = old_w + 100 - result = np.full((new_h, new_w, c), (255, 255, 255), dtype=np.uint8) - x_center = (new_w - old_w) // 2 - y_center = (new_h - old_h) // 2 - result[y_center:y_center+old_h, x_center:x_center+old_w] = sect - - #bounding rectangle preprocessing - gray = cv2.cvtColor(result, cv2.COLOR_BGR2GRAY) - blur = cv2.GaussianBlur(gray, (1, 1), 0) - thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] - kernal = cv2.getStructuringElement(cv2.MORPH_RECT, (8, 1)) - dilate = cv2.dilate(thresh, kernal, iterations=7) - conts = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - - conts = conts[0] if len(conts) == 2 else conts[1] - conts = sorted(conts, key=lambda x: cv2.boundingRect(x)[0]) - - ret = [] - - for c in conts: - rect = cv2.minAreaRect(c) - box = cv2.boxPoints(rect) - box = np.int0(box) - result = cv2.drawContours(result,[box],0,(0,255,0),2) - origin = box[0] - new_pts = sorted(box, key=lambda x: dist(x, origin)) - pt0 = new_pts[0] - pt1 = new_pts[1] - pt2 = new_pts[2] - pt3 = new_pts[3] - - mid1 = [int((pt0[0] + pt1[0]) / 2) - 50, int((pt0[1] + pt1[1]) / 2) - 50] - mid2 = [int((pt2[0] + pt3[0]) / 2) - 50, int((pt2[1] + pt3[1]) / 2) - 50] - ret = [mid1, mid2] - - return ret + class MiyaoStaffinding(RodanTask): name = 'Miyao Staff Finding' @@ -123,13 +40,6 @@ class MiyaoStaffinding(RodanTask): 'type': 'boolean', 'default': True, 'description': 'Interpolate found line points so all lines have the same number of points. This MUST be True for pitch finding to succeed.' - }, - 'Slices': { - 'type': 'integer', - 'default': 8, - 'minimum': 1, - 'maximum': 24, - 'description': '(For One Staff Finding) Number of divisions per staff line' } } } @@ -151,60 +61,6 @@ class MiyaoStaffinding(RodanTask): }] def run_my_task(self, inputs, settings, outputs): - - #Branch for one staff finding - if settings['Number of lines'] == 1: - image = cv2.imread(inputs['Image containing staves (RGB, greyscale, or onebit)'][0]['resource_path']) - slices = settings['Slices'] - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - blur = cv2.GaussianBlur(gray, (1, 1), 0) - thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] - kernal = cv2.getStructuringElement(cv2.MORPH_RECT, (8, 1)) - dilate = cv2.dilate(thresh, kernal, iterations=7) - conts = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - - conts = conts[0] if len(conts) == 2 else conts[1] - conts = sorted(conts, key=lambda x: cv2.boundingRect(x)[0]) - - ret = [] - - for c in conts: - x, y, w, h = cv2.boundingRect(c) - part = w // slices - last = [] - lines = [] - for i in range(0, slices): - img_sect = image[y:y+h, x+(part*i):x+(part*(i+1))] - line = padding(img_sect) - if line != []: - line[0][0] += x+(part*i) - line[1][0] += x+(part*i) - line[0][1] += y - line[1][1] += y - - #normalize lines to bounds - new_y1 = int(coords(line[0][0], line[0][1], line[1][0], line[1][1], x+(part*i))) - new_y2 = int(coords(line[0][0], line[0][1], line[1][0], line[1][1], x+(part*(i+1)))) - line[0] = [x+(part*i), new_y1] - line[1] = [x+(part*(i+1)), new_y2] - - #make sure lines connect together - if last != []: - line[0] = last - last = line[1] - lines.append(line[0]) - - #draw line - - ret.append(([x, y, w, h], lines)) - - jsomr = to_json(image, ret) - - outfile_path = outputs['JSOMR'][0]['resource_path'] - with open(outfile_path, "w") as outfile: - outfile.write(json.dumps(jsomr)) - return True - # Inputs image = load_image(inputs['Image containing staves (RGB, greyscale, or onebit)'][0]['resource_path']) diff --git a/rodan-main/code/rodan/registerJobs.yaml b/rodan-main/code/rodan/registerJobs.yaml index a7bc32d3f..a034858de 100644 --- a/rodan-main/code/rodan/registerJobs.yaml +++ b/rodan-main/code/rodan/registerJobs.yaml @@ -5,9 +5,8 @@ "rodan.jobs.labeler" : [ "Labeler" ] } "RODAN_PYTHON3_JOBS": { - #new job - "rodan.jobs.one_staff_finding":{ - "rodan.jobs.one_staff_finding.one_staff_finding": ["OneStaffFinding"] + "rodan.jobs.aquitanian_ref_line_finding":{ + "rodan.jobs.aquitanian_ref_line_finding.aquitanian_ref_line_finding": ["AquitanianReferenceLineFinding"] }, "rodan.jobs.helloworld": { "rodan.jobs.helloworld.helloworld" : ["HelloWorld"], From fa83497395e80f44cd5ba9125d143f06c7302d29 Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Wed, 26 Mar 2025 00:17:36 -0400 Subject: [PATCH 04/11] fixed json formatting error --- .../aquitanian_ref_line_finding.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py index 0d16e4bd4..0a7b5e4b0 100644 --- a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py +++ b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py @@ -82,9 +82,9 @@ def to_json(img, data): "nrows": h, "ulx": 0, "uly": 0 - }, - "staves": staves - } + } + }, + "staves": staves } class AquitanianReferenceLineFinding(RodanTask): From 0e39139bb1f94ed159442dccaff29fb446625ce9 Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Wed, 26 Mar 2025 10:11:18 -0400 Subject: [PATCH 05/11] Fixed MEI Encoding to handle different num_lines --- .../code/rodan/jobs/MEI_encoding/build_mei_file.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/rodan-main/code/rodan/jobs/MEI_encoding/build_mei_file.py b/rodan-main/code/rodan/jobs/MEI_encoding/build_mei_file.py index b899429ee..4165abeb7 100644 --- a/rodan-main/code/rodan/jobs/MEI_encoding/build_mei_file.py +++ b/rodan-main/code/rodan/jobs/MEI_encoding/build_mei_file.py @@ -164,7 +164,7 @@ def neume_to_lyric_alignment( return pairs -def generate_base_document(column_split_info: Optional[dict]): +def generate_base_document(column_split_info: Optional[dict], staves: Optional[list]): """ Generates a generic template for an MEI document for neume notation. @@ -206,7 +206,10 @@ def generate_base_document(column_split_info: Optional[dict]): staffDef = new_el("staffDef", staffGrp) staffDef.set("n", "1") - staffDef.set("lines", "4") + if staves is not None: + staffDef.set("lines", str(staves[0]["num_lines"])) + else: + staffDef.set("lines", "4") staffDef.set("notationtype", "neume") staffDef.set("clef.line", "4") staffDef.set("clef.shape", "C") @@ -585,7 +588,7 @@ def build_mei( @staves: Bounding box information from pitch finding JSON. @page: Page dimension information from pitch finding JSON. """ - meiDoc, surface, layer = generate_base_document(column_split_info) + meiDoc, surface, layer = generate_base_document(column_split_info, staves) # set the bounds of the page. If this is multi column then this will be overwritten surface_bb = { From 85c5dacabf762fe5260c57cab2c39a50cf171889 Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Wed, 26 Mar 2025 16:59:07 -0400 Subject: [PATCH 06/11] Revert "fixed json formatting error" This reverts commit fa83497395e80f44cd5ba9125d143f06c7302d29. --- .../aquitanian_ref_line_finding.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py index 0a7b5e4b0..0d16e4bd4 100644 --- a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py +++ b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py @@ -82,9 +82,9 @@ def to_json(img, data): "nrows": h, "ulx": 0, "uly": 0 - } - }, - "staves": staves + }, + "staves": staves + } } class AquitanianReferenceLineFinding(RodanTask): From 2171896cafa4f1f86d43fb18071901dfdc262ff8 Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Thu, 27 Mar 2025 00:21:48 -0400 Subject: [PATCH 07/11] fixed json formatting error --- .../aquitanian_ref_line_finding.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py index 0d16e4bd4..42f20f781 100644 --- a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py +++ b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py @@ -82,9 +82,9 @@ def to_json(img, data): "nrows": h, "ulx": 0, "uly": 0 - }, - "staves": staves - } + } + }, + "staves": staves } class AquitanianReferenceLineFinding(RodanTask): @@ -193,4 +193,4 @@ def run_my_task(self, inputs, settings, outputs): overlay_save = Image.fromarray(img) overlay_save.save(outfile_path2, 'PNG') - return True \ No newline at end of file + return True From 95f170edba00d7050cbefcc3a98cdf05045f024d Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Mon, 28 Apr 2025 16:02:44 -0400 Subject: [PATCH 08/11] fix: changes bounding box size to surround whole staff section --- .../rodan/jobs/MEI_encoding/build_mei_file.py | 9 ++---- .../aquitanian_ref_line_finding.py | 30 ++++++++++++++----- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/rodan-main/code/rodan/jobs/MEI_encoding/build_mei_file.py b/rodan-main/code/rodan/jobs/MEI_encoding/build_mei_file.py index 4165abeb7..b899429ee 100644 --- a/rodan-main/code/rodan/jobs/MEI_encoding/build_mei_file.py +++ b/rodan-main/code/rodan/jobs/MEI_encoding/build_mei_file.py @@ -164,7 +164,7 @@ def neume_to_lyric_alignment( return pairs -def generate_base_document(column_split_info: Optional[dict], staves: Optional[list]): +def generate_base_document(column_split_info: Optional[dict]): """ Generates a generic template for an MEI document for neume notation. @@ -206,10 +206,7 @@ def generate_base_document(column_split_info: Optional[dict], staves: Optional[l staffDef = new_el("staffDef", staffGrp) staffDef.set("n", "1") - if staves is not None: - staffDef.set("lines", str(staves[0]["num_lines"])) - else: - staffDef.set("lines", "4") + staffDef.set("lines", "4") staffDef.set("notationtype", "neume") staffDef.set("clef.line", "4") staffDef.set("clef.shape", "C") @@ -588,7 +585,7 @@ def build_mei( @staves: Bounding box information from pitch finding JSON. @page: Page dimension information from pitch finding JSON. """ - meiDoc, surface, layer = generate_base_document(column_split_info, staves) + meiDoc, surface, layer = generate_base_document(column_split_info) # set the bounds of the page. If this is multi column then this will be overwritten surface_bb = { diff --git a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py index 42f20f781..ec44af1c4 100644 --- a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py +++ b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py @@ -55,7 +55,7 @@ def padding(sect): return ret -def to_json(img, data): +def to_json(img, data, neume_size): h, w, _ = img.shape staves = [] for i in range(0, len(data)): @@ -63,16 +63,21 @@ def to_json(img, data): box = cur[0] x, y, width, height = box lines = cur[1] + up_ledger_2 = [[x[0], x[1] - (2 * neume_size)] for x in lines] + up_ledger_1 = [[x[0], x[1] - neume_size] for x in lines] + down_ledger_1 = [[x[0], x[1] + neume_size] for x in lines] + down_ledger_2 = [[x[0], x[1] + (2 * neume_size)] for x in lines] staves.append({ - "staff_no": i, + "staff_no": i+1, "bounding_box":{ "ncols": width, - "nrows": height, + "nrows": height + (4 * neume_size), "ulx": x, - "uly": y + "uly": y - (2 * neume_size) }, "num_lines": 1, - "line_positions": lines + "line_positions": [up_ledger_2, up_ledger_1, lines, down_ledger_1, down_ledger_2] + #"line_positions": [lines] }) return { "page":{ @@ -95,7 +100,7 @@ class AquitanianReferenceLineFinding(RodanTask): 'title': 'Settings', 'type': 'object', 'job_queue': 'Python3', - 'required': ['Slices'], + 'required': ['Slices', 'Neume Height'], 'properties': { 'Slices': { 'type': 'integer', @@ -103,9 +108,17 @@ class AquitanianReferenceLineFinding(RodanTask): 'minimum': 1, 'maximum': 24, 'description': 'Number of divisions per single reference line' + }, + 'Neume Height': { + 'type': 'integer', + 'default': 50, + 'minimum': 1, + 'maximum': 500, + 'description': "Height of neumes (for generating ledger lines)" } } } + enabled = True category = "Staff Detection" interactive = False @@ -175,13 +188,16 @@ def run_my_task(self, inputs, settings, outputs): line[0] = last last = line[1] lines.append(line[0]) + if i == (slices - 1): + lines.append(line[1]) #draw line if overlay: cv2.line(img, tuple(line[0]), tuple(line[1]), (255, 0, 0), 2) ret.append(([x, y, w, h], lines)) - jsomr = to_json(img, ret) + neume_size = settings['Neume Height'] + jsomr = to_json(img, ret, neume_size) outfile_path = outputs['JSOMR'][0]['resource_path'] with open(outfile_path, "w") as outfile: From e5dcceb643848fca105addbd6346d96d9513b1b4 Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Wed, 7 May 2025 17:33:39 -0400 Subject: [PATCH 09/11] docs: updated setting for neume height to be 3 * neume height --- .../aquitanian_ref_line_finding/aquitanian_ref_line_finding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py index ec44af1c4..dd3851311 100644 --- a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py +++ b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py @@ -114,7 +114,7 @@ class AquitanianReferenceLineFinding(RodanTask): 'default': 50, 'minimum': 1, 'maximum': 500, - 'description': "Height of neumes (for generating ledger lines)" + 'description': "Neume Height multiplied by 3 (for generating ledger lines)" } } } From fe93fc5c952c692adad89a3b05784c8db1c1c16b Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Fri, 30 May 2025 23:01:48 -0700 Subject: [PATCH 10/11] fix: sorts staff lines based on y height --- .../aquitanian_ref_line_finding.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py index dd3851311..7953f365f 100644 --- a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py +++ b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py @@ -89,7 +89,7 @@ def to_json(img, data, neume_size): "uly": 0 } }, - "staves": staves + "staves": staves#sorted(staves, key=lambda x: x["line_positions"][0][0][1]) } class AquitanianReferenceLineFinding(RodanTask): @@ -162,7 +162,6 @@ def run_my_task(self, inputs, settings, outputs): conts = sorted(conts, key=lambda x: cv2.boundingRect(x)[0]) ret = [] - for c in conts: x, y, w, h = cv2.boundingRect(c) part = w // slices @@ -190,12 +189,14 @@ def run_my_task(self, inputs, settings, outputs): lines.append(line[0]) if i == (slices - 1): lines.append(line[1]) - + #draw line if overlay: cv2.line(img, tuple(line[0]), tuple(line[1]), (255, 0, 0), 2) ret.append(([x, y, w, h], lines)) + #sort staff lines based on y height + ret = sorted(ret, key=lambda x: x[0][1]) neume_size = settings['Neume Height'] jsomr = to_json(img, ret, neume_size) @@ -205,7 +206,6 @@ def run_my_task(self, inputs, settings, outputs): if overlay: outfile_path2 = outputs["Overlayed Lines"][0]["resource_path"] - #img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB) overlay_save = Image.fromarray(img) overlay_save.save(outfile_path2, 'PNG') From 74ef7532c35c26ea7edb772748b243ad3fd11087 Mon Sep 17 00:00:00 2001 From: DeannaLC Date: Wed, 18 Jun 2025 21:19:00 -0700 Subject: [PATCH 11/11] docs: comments in aquitanian_ref_line_finding.py to document functions and overall workflow --- .../aquitanian_ref_line_finding.py | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py index 7953f365f..e996cac25 100644 --- a/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py +++ b/rodan-main/code/rodan/jobs/aquitanian_ref_line_finding/aquitanian_ref_line_finding.py @@ -6,15 +6,19 @@ import json import json.encoder +#Helper function for distance formula def dist(pt1, pt2): return math.sqrt(((pt1[0] - pt2[0])**2) + ((pt1[1] - pt2[1])**2)) +#Helper function to find the matching y value given an x value and 2 points of a line def coords(x1, y1, x2, y2, new_x): slope = (y2 - y1) / (x2 - x1) b = (-1 * (slope * x1)) + y1 return (slope * new_x) + b -def padding(sect): +#Handles each line segment by adding extra space, drawing a bounding box, and drawing a line through the center +#Returns the 2 coords for the line +def process_section(sect): #add extra space around the line segments old_h, old_w, c = sect.shape new_h = old_h + 100 @@ -24,7 +28,7 @@ def padding(sect): y_center = (new_h - old_h) // 2 result[y_center:y_center+old_h, x_center:x_center+old_w] = sect - #bounding rectangle preprocessing + #preprocess image, draw bounding box around gray = cv2.cvtColor(result, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (1, 1), 0) thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] @@ -37,6 +41,7 @@ def padding(sect): ret = [] + #draw line through the bounding box for c in conts: rect = cv2.minAreaRect(c) box = cv2.boxPoints(rect) @@ -55,6 +60,8 @@ def padding(sect): return ret +#Convert results into a JSOMR format +#Each reference line has 5 lines: 2 ledger lines below, the original line, and 2 ledger lines above def to_json(img, data, neume_size): h, w, _ = img.shape staves = [] @@ -77,7 +84,6 @@ def to_json(img, data, neume_size): }, "num_lines": 1, "line_positions": [up_ledger_2, up_ledger_1, lines, down_ledger_1, down_ledger_2] - #"line_positions": [lines] }) return { "page":{ @@ -89,7 +95,7 @@ def to_json(img, data, neume_size): "uly": 0 } }, - "staves": staves#sorted(staves, key=lambda x: x["line_positions"][0][0][1]) + "staves": staves } class AquitanianReferenceLineFinding(RodanTask): @@ -144,6 +150,13 @@ class AquitanianReferenceLineFinding(RodanTask): 'maximum': 1 }] + # Overall Workflow + # 1. Draw bounding boxes around each reference line + # 2. Split each bounding box into a number of sections given by slices + # 3. Add extra space around each line segment, then draws another bounding box (not necessarily rectangular) + # 4. Draw a line through the center of the line segment bounding box + # 5. Link each segment together + # 6. After all lines are found, convert into JSOMR format def run_my_task(self, inputs, settings, outputs): input_path = inputs["Image containing staves (RGB, greyscale, or onebit)"][0]["resource_path"] overlay = "Overlayed Lines" in outputs @@ -151,6 +164,7 @@ def run_my_task(self, inputs, settings, outputs): img = cv2.imread(input_path) + #Image preprocessing to set up bounding boxes gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (1, 1), 0) thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] @@ -162,6 +176,8 @@ def run_my_task(self, inputs, settings, outputs): conts = sorted(conts, key=lambda x: cv2.boundingRect(x)[0]) ret = [] + + #Split each bounding box into sections, then connect the line segments for c in conts: x, y, w, h = cv2.boundingRect(c) part = w // slices @@ -169,7 +185,7 @@ def run_my_task(self, inputs, settings, outputs): lines = [] for i in range(0, slices): img_sect = img[y:y+h, x+(part*i):x+(part*(i+1))] - line = padding(img_sect) + line = process_section(img_sect) if line != []: line[0][0] += x+(part*i) line[1][0] += x+(part*i) @@ -182,7 +198,7 @@ def run_my_task(self, inputs, settings, outputs): line[0] = [x+(part*i), new_y1] line[1] = [x+(part*(i+1)), new_y2] - #make sure lines connect together + #make sure line segments connect together if last != []: line[0] = last last = line[1] @@ -193,11 +209,14 @@ def run_my_task(self, inputs, settings, outputs): #draw line if overlay: cv2.line(img, tuple(line[0]), tuple(line[1]), (255, 0, 0), 2) + #save bounding box and line points ret.append(([x, y, w, h], lines)) #sort staff lines based on y height ret = sorted(ret, key=lambda x: x[0][1]) neume_size = settings['Neume Height'] + + #convert data into jsomr format jsomr = to_json(img, ret, neume_size) outfile_path = outputs['JSOMR'][0]['resource_path']