diff --git a/flows/main.json b/flows/main.json index bb3a3ec..8c79512 100644 --- a/flows/main.json +++ b/flows/main.json @@ -991,33 +991,6 @@ "width": 8, "height": 1 }, - { - "id": "7fdc961c.837a1", - "type": "ui_spacer", - "name": "spacer", - "group": "46be9c86.dea684", - "order": 3, - "width": 3, - "height": 1 - }, - { - "id": "ff9fd243.02bfd", - "type": "ui_spacer", - "name": "spacer", - "group": "46be9c86.dea684", - "order": 5, - "width": 3, - "height": 1 - }, - { - "id": "6f51c860.a27ff", - "type": "ui_spacer", - "name": "spacer", - "group": "46be9c86.dea684", - "order": 7, - "width": 10, - "height": 1 - }, { "id": "cfe2288f.a8862", "type": "ui_group", @@ -1029,6 +1002,33 @@ "width": "6", "collapse": false }, + { + "id": "934b51b3.585b28", + "type": "ui_spacer", + "name": "spacer", + "group": "abeb6dad.635a2", + "order": 9, + "width": 1, + "height": 1 + }, + { + "id": "626cde70.2c2918", + "type": "ui_spacer", + "name": "spacer", + "group": "46be9c86.dea684", + "order": 4, + "width": 3, + "height": 1 + }, + { + "id": "6bacc64.0a25c38", + "type": "ui_spacer", + "name": "spacer", + "group": "46be9c86.dea684", + "order": 6, + "width": 3, + "height": 1 + }, { "id": "4e78af2d.90be7", "type": "ui_ui_control", @@ -1059,8 +1059,8 @@ "payload": "{\"tab\":\"Home\"}", "payloadType": "json", "topic": "", - "x": 410, - "y": 460, + "x": 430, + "y": 600, "wires": [ [ "f0fb77cf.8f1c28" @@ -1073,8 +1073,8 @@ "z": "cb95299c.2817c8", "name": "", "events": "change", - "x": 720, - "y": 460, + "x": 740, + "y": 600, "wires": [ [] ] @@ -1482,7 +1482,6 @@ "passthru": true, "decouple": "false", "topic": "", - "topicType": "str", "style": "", "onvalue": "on", "onvalueType": "str", @@ -1492,7 +1491,6 @@ "offvalueType": "str", "officon": "", "offcolor": "", - "animate": true, "x": 940, "y": 40, "wires": [ @@ -1910,9 +1908,9 @@ "passthru": true, "outs": "end", "topic": "pump_flowrate", - "min": "0.1", - "max": "20", - "step": "0.1", + "min": "0.5", + "max": "100", + "step": "0.5", "x": 540, "y": 340, "wires": [ @@ -3075,7 +3073,6 @@ "passthru": true, "outs": "end", "topic": "imager/image", - "topicType": "str", "min": "125", "max": "1000", "step": "1", @@ -3224,7 +3221,7 @@ "z": "cb95299c.2817c8", "name": "Start segmentation", "group": "abeb6dad.635a2", - "order": 4, + "order": 10, "width": 5, "height": 1, "passthru": false, @@ -3236,12 +3233,12 @@ "payload": "{\"action\":\"segment\"}", "payloadType": "json", "topic": "segmenter/segment", - "topicType": "str", "x": 370, - "y": 300, + "y": 460, "wires": [ [ - "33c28dc1.238002" + "33c28dc1.238002", + "c72a1064.1ec388" ] ] }, @@ -3249,10 +3246,11 @@ "id": "27be7971.b3fbce", "type": "ui_button", "z": "cb95299c.2817c8", + "d": true, "name": "Stop segmentation", "group": "abeb6dad.635a2", - "order": 5, - "width": 5, + "order": 8, + "width": 4, "height": 1, "passthru": true, "label": "Stop segmentation", @@ -3264,7 +3262,7 @@ "payloadType": "json", "topic": "segmenter/segment", "x": 370, - "y": 340, + "y": 520, "wires": [ [ "16f3cef4.0acac9" @@ -3280,8 +3278,8 @@ "qos": "", "retain": "", "broker": "8dc3722c.06efa8", - "x": 810, - "y": 340, + "x": 850, + "y": 520, "wires": [] }, { @@ -4499,18 +4497,18 @@ "id": "8f3788f6.ddcf98", "type": "function", "z": "cb95299c.2817c8", - "name": "obj_counter.js", - "func": "obj_counter=global.get('obj_counter')\nobj_counter=obj_counter+1\nglobal.set('obj_counter',obj_counter)\nmsg.payload = obj_counter\nreturn msg;", + "name": "obj_counter", + "func": "obj_counter = flow.get('obj_counter')\nobj_counter = obj_counter + 1\nflow.set('obj_counter', obj_counter)\nmsg.payload = obj_counter\nmsg.topic = \"object count\"\nreturn msg", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", - "x": 820, - "y": 840, + "x": 810, + "y": 800, "wires": [ [ - "9d53dbe2.dbffe8", - "fa3b7929.ac7da8" + "fa3b7929.ac7da8", + "9d53dbe2.dbffe8" ] ] }, @@ -4570,7 +4568,7 @@ "z": "cb95299c.2817c8", "name": "counter graph", "group": "46be9c86.dea684", - "order": 6, + "order": 7, "width": 10, "height": 2, "label": "", @@ -4611,7 +4609,7 @@ "type": "function", "z": "cb95299c.2817c8", "name": "ex : area", - "func": "msg.payload=msg.payload.object_area\nmsg.topic=\"area\"\n\nreturn msg;", + "func": "// Payload looks like this:\n// {'name': '01_13_28_232066_0',\n// 'metadata': {\n// 'label': 0, 'width': 29, 'height': 80, ....\n\nmsg.payload=msg.payload.metadata.area_exc\nmsg.topic=\"area\"\n\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", @@ -4678,7 +4676,7 @@ "#c5b0d5" ], "outputs": 1, - "x": 1320, + "x": 1330, "y": 920, "wires": [ [] @@ -4697,7 +4695,7 @@ "targetType": "full", "statusVal": "", "statusType": "auto", - "x": 840, + "x": 830, "y": 760, "wires": [] }, @@ -4770,7 +4768,7 @@ "type": "ui_text", "z": "cb95299c.2817c8", "group": "46be9c86.dea684", - "order": 4, + "order": 5, "width": 4, "height": 1, "name": "counter", @@ -4799,32 +4797,15 @@ "crontab": "", "once": true, "onceDelay": 0.1, - "topic": "", + "topic": "object count", "payload": "0", "payloadType": "num", "x": 810, - "y": 800, + "y": 880, "wires": [ [ - "cd10a556.6a9a08" - ] - ] - }, - { - "id": "cd10a556.6a9a08", - "type": "function", - "z": "cb95299c.2817c8", - "name": "obj_counter init", - "func": "obj_counter=0\nglobal.set('obj_counter',obj_counter)\nmsg.payload = obj_counter\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "x": 1000, - "y": 800, - "wires": [ - [ - "fa3b7929.ac7da8" + "ac439738.28bc18", + "4f3f7c4a.cb21c4" ] ] }, @@ -5200,8 +5181,8 @@ "noerr": 0, "initialize": "", "finalize": "", - "x": 760, - "y": 240, + "x": 780, + "y": 380, "wires": [ [] ] @@ -5212,7 +5193,7 @@ "z": "cb95299c.2817c8", "group": "46be9c86.dea684", "name": "Object counts", - "order": 2, + "order": 3, "width": 0, "height": 0, "format": "

Object counts

", @@ -5240,7 +5221,7 @@ "fwdInMessages": true, "resendOnRefresh": true, "templateScope": "local", - "x": 1320, + "x": 1330, "y": 880, "wires": [ [] @@ -5550,13 +5531,13 @@ "type": "function", "z": "cb95299c.2817c8", "name": "prepare segmentation", - "func": "global.set('obj_counter', 0);\n\nvar segmentation_list = flow.get('segmentation_list')\nif (segmentation_list !== undefined && segmentation_list !== \"\") {\n msg.payload['path'] = segmentation_list\n}\n\nreturn msg;", + "func": "global.set('obj_counter', 0);\n\nvar segmentation_list = flow.get('segmentation_list')\nif (segmentation_list !== undefined && segmentation_list !== \"\") {\n msg.payload['path'] = segmentation_list\n}\n\n\nvar force = flow.get('force')\nif (force !== undefined && force !== \"\") {\n msg.payload['settings'] = {\"force\":Boolean(force)}\n}\n\n\nvar recursive = flow.get('recursive')\nif (recursive !== undefined && recursive !== \"\") {\n if (\"settings\" in msg.payload){\n msg.payload.settings[\"recursive\"] = Boolean(recursive)\n }\n else{\n msg.payload['settings'] = {\"recursive\": Boolean(recursive)}\n }\n}\n\nvar ecotaxa = flow.get('ecotaxa')\nif (ecotaxa !== undefined && ecotaxa !== \"\") {\n if (\"settings\" in msg.payload){\n msg.payload.settings[\"ecotaxa\"] = Boolean(ecotaxa)\n }\n else{\n msg.payload['settings'] = {\"ecotaxa\": Boolean(ecotaxa)}\n }\n}\n\nvar keep = flow.get('keep')\nif (keep !== undefined && keep !== \"\") {\n if (\"settings\" in msg.payload){\n msg.payload.settings[\"keep\"] = Boolean(keep)\n }\n else{\n msg.payload['settings'] = {\"keep\": Boolean(keep)}\n }\n}\n\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", - "x": 600, - "y": 300, + "x": 620, + "y": 460, "wires": [ [ "16f3cef4.0acac9" @@ -6197,7 +6178,6 @@ "payload": "", "payloadType": "str", "topic": "", - "topicType": "str", "x": 400, "y": 600, "wires": [ @@ -6590,8 +6570,7 @@ "payload": "", "payloadType": "str", "topic": "update", - "topicType": "str", - "x": 300, + "x": 260, "y": 460, "wires": [ [ @@ -6610,7 +6589,7 @@ "timer": "", "oldrc": false, "name": "Update", - "x": 480, + "x": 460, "y": 460, "wires": [ [ @@ -6659,7 +6638,7 @@ "raw": false, "topic": "", "name": "Update notif", - "x": 1030, + "x": 730, "y": 440, "wires": [ [] @@ -8083,8 +8062,8 @@ "z": "cb95299c.2817c8", "name": "", "env": [], - "x": 410, - "y": 240, + "x": 430, + "y": 380, "wires": [ [ "3b72d11c.86e9e6" @@ -8101,8 +8080,8 @@ "noerr": 0, "initialize": "", "finalize": "", - "x": 580, - "y": 240, + "x": 600, + "y": 380, "wires": [ [ "c8749cbb.55254" @@ -8987,20 +8966,19 @@ "z": "cb95299c.2817c8", "group": "abeb6dad.635a2", "name": "", - "order": 3, + "order": 7, "width": 10, "height": 11, - "lineType": "two", + "lineType": "one", "actionType": "check", "allowHTML": false, "outputs": 1, "topic": "", - "x": 650, + "x": 770, "y": 140, "wires": [ [ - "cb3b87b5.63c4", - "739ba213.584e3c" + "cb3b87b5.63c4" ] ] }, @@ -9010,7 +8988,7 @@ "z": "cb95299c.2817c8", "name": "", "dirname": "/home/pi/data/img/", - "pathRegex": ".*", + "pathRegex": "", "isRecursive": true, "findDir": true, "isArray": true, @@ -9018,7 +8996,7 @@ "y": 140, "wires": [ [ - "3ea12061.ce62c" + "ba2947.c854deb8" ] ] }, @@ -9028,7 +9006,7 @@ "z": "cb95299c.2817c8", "name": "Refresh", "group": "abeb6dad.635a2", - "order": 2, + "order": 6, "width": 0, "height": 0, "passthru": false, @@ -9040,7 +9018,6 @@ "payload": "", "payloadType": "date", "topic": "update", - "topicType": "str", "x": 260, "y": 140, "wires": [ @@ -9297,9 +9274,8 @@ "options": [], "payload": "", "topic": "branch", - "topicType": "str", - "x": 1020, - "y": 260, + "x": 1160, + "y": 320, "wires": [ [ "2d2ef1fd.40e6e6" @@ -9350,29 +9326,6 @@ ] ] }, - { - "id": "d037a624.60bea8", - "type": "exec", - "z": "9daf9e2b.019fc", - "d": true, - "command": "git --git-dir=/home/pi/PlanktonScope/.git checkout", - "addpay": true, - "append": "", - "useSpawn": "false", - "timer": "", - "oldrc": false, - "name": "git checkout branch", - "x": 1230, - "y": 260, - "wires": [ - [], - [ - "d334d264.8a7728", - "83c5a708.a5715" - ], - [] - ] - }, { "id": "af2b8d95.195bb8", "type": "ui_text", @@ -9385,7 +9338,7 @@ "label": "Current code version", "format": "{{msg.payload}}", "layout": "row-center", - "x": 920, + "x": 860, "y": 320, "wires": [] }, @@ -9394,12 +9347,12 @@ "type": "function", "z": "cb95299c.2817c8", "name": "update segmentation_list", - "func": "var segmentation_list = flow.get('segmentation_list');\n\nif (segmentation_list === undefined || segmentation_list === \"\") {\n segmentation_list = []\n console.log(\"error\")\n}\n\nif (msg.payload.isChecked){\n if (segmentation_list.includes(msg.payload.title) === false){\n segmentation_list.push(msg.payload.title)\n }\n // Element already in list, don't push it more than once\n //segmentation_list.push(msg.payload[\"title\"])\n}\nelse {\n var pos = segmentation_list.indexOf('machin')\n segmentation_list.splice(pos, 1)\n}\n\nflow.set('segmentation_list', segmentation_list)", + "func": "var segmentation_list = flow.get('segmentation_list');\n\nif (segmentation_list === undefined || segmentation_list === \"\") {\n segmentation_list = []\n console.log(\"error\")\n}\n\npath = \"/home/pi/data/img/\" + msg.payload.title\n\nif (msg.payload.isChecked){\n if (segmentation_list.includes(path) === false){\n segmentation_list.push(path)\n }\n // Element already in list, don't push it more than once\n //segmentation_list.push(msg.payload[\"title\"])\n}\nelse {\n var pos = segmentation_list.indexOf(path)\n segmentation_list.splice(pos, 1)\n}\n\nflow.set('segmentation_list', segmentation_list)", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", - "x": 910, + "x": 950, "y": 140, "wires": [ [] @@ -9438,7 +9391,7 @@ "name": "Update message", "order": 1, "width": 10, - "height": "3", + "height": 3, "format": "
You can choose here in which folder(s) you want the segmentation script to run. A few details though:\n
The segmentation is run recursively in all folders. So if you select a top level folder, the segmentation will be run in all subfolders.\n
Also, you will be able to chose wether for force the segmentation for folders in which it has run already.
", "storeOutMessages": true, "fwdInMessages": true, @@ -9645,7 +9598,6 @@ "payload": "", "payloadType": "str", "topic": "topic", - "topicType": "msg", "x": 670, "y": 1480, "wires": [ @@ -9654,16 +9606,6 @@ ] ] }, - { - "id": "c98d3495.b04278", - "type": "comment", - "z": "9daf9e2b.019fc", - "name": "", - "info": "Ajouter ici un update en fait vers la nouvelle branche (il faut qu'on éteigne et rallume les scripts et tout le tintouin)", - "x": 1260, - "y": 280, - "wires": [] - }, { "id": "c60030d4.317418", "type": "exec", @@ -9717,23 +9659,6 @@ ] ] }, - { - "id": "739ba213.584e3c", - "type": "debug", - "z": "cb95299c.2817c8", - "name": "", - "active": true, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "true", - "targetType": "full", - "statusVal": "", - "statusType": "auto", - "x": 850, - "y": 200, - "wires": [] - }, { "id": "b6bc9b81.ff942", "type": "function", @@ -9772,8 +9697,8 @@ "noerr": 0, "initialize": "", "finalize": "", - "x": 590, - "y": 520, + "x": 860, + "y": 360, "wires": [ [ "5112444.5be803c" @@ -9795,5 +9720,504 @@ "16548734.7fe631" ] ] + }, + { + "id": "ba2947.c854deb8", + "type": "function", + "z": "cb95299c.2817c8", + "name": "remove common", + "func": "function remove_path(item, index, array) {\n array[index] = item.replace(\"/home/pi/data/img/\", \"\")\n} \n\nmsg.payload.forEach(remove_path)\n\nreturn msg", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "x": 620, + "y": 140, + "wires": [ + [ + "3ea12061.ce62c" + ] + ] + }, + { + "id": "21af0db1.c2c182", + "type": "ui_multistate_switch", + "z": "cb95299c.2817c8", + "name": "recursive toggle", + "group": "abeb6dad.635a2", + "order": 2, + "width": 5, + "height": 1, + "label": "Recursive folder", + "stateField": "payload", + "enableField": "enable", + "rounded": true, + "useThemeColors": true, + "hideSelectedLabel": false, + "options": [ + { + "label": "No", + "value": "0", + "valueType": "num", + "color": "#009933" + }, + { + "label": "Yes", + "value": "1", + "valueType": "num", + "color": "#999999" + } + ], + "x": 480, + "y": 220, + "wires": [ + [ + "880b192a.88e2d" + ] + ] + }, + { + "id": "dffb9881.feef8", + "type": "ui_multistate_switch", + "z": "cb95299c.2817c8", + "name": "force toggle", + "group": "abeb6dad.635a2", + "order": 3, + "width": 5, + "height": 1, + "label": "Force rework", + "stateField": "payload", + "enableField": "enable", + "rounded": true, + "useThemeColors": true, + "hideSelectedLabel": false, + "options": [ + { + "label": "No", + "value": "0", + "valueType": "num", + "color": "#009933" + }, + { + "label": "Yes", + "value": "1", + "valueType": "num", + "color": "#999999" + } + ], + "x": 470, + "y": 300, + "wires": [ + [ + "a4f68fa6.5d77f8" + ] + ] + }, + { + "id": "880b192a.88e2d", + "type": "change", + "z": "cb95299c.2817c8", + "name": "", + "rules": [ + { + "t": "set", + "p": "recursive", + "pt": "flow", + "to": "payload", + "tot": "msg" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 690, + "y": 240, + "wires": [ + [] + ] + }, + { + "id": "a4f68fa6.5d77f8", + "type": "change", + "z": "cb95299c.2817c8", + "name": "", + "rules": [ + { + "t": "set", + "p": "force", + "pt": "flow", + "to": "payload", + "tot": "msg" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 670, + "y": 280, + "wires": [ + [] + ] + }, + { + "id": "1ef1b43b.b0f064", + "type": "inject", + "z": "cb95299c.2817c8", + "name": "0", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "0", + "payloadType": "num", + "x": 270, + "y": 280, + "wires": [ + [ + "dffb9881.feef8", + "a4f68fa6.5d77f8" + ] + ] + }, + { + "id": "f078c068.eacf58", + "type": "ui_template", + "z": "cb95299c.2817c8", + "group": "46be9c86.dea684", + "name": "Stream Segmented object", + "order": 2, + "width": 10, + "height": 8, + "format": "
\n Latest object segmented:
\n \"If\n
", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": false, + "templateScope": "local", + "x": 1370, + "y": 600, + "wires": [ + [] + ] + }, + { + "id": "43f97b93.b76294", + "type": "inject", + "z": "cb95299c.2817c8", + "name": "1", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "1", + "payloadType": "num", + "x": 270, + "y": 240, + "wires": [ + [ + "21af0db1.c2c182", + "880b192a.88e2d" + ] + ] + }, + { + "id": "ac439738.28bc18", + "type": "function", + "z": "cb95299c.2817c8", + "name": "chart init", + "func": "obj=[{\"series\": [],\"data\": [],\"labels\": []}]\nmsg.payload = obj\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "x": 980, + "y": 880, + "wires": [ + [ + "458cd82e.03d258" + ] + ] + }, + { + "id": "4f3f7c4a.cb21c4", + "type": "change", + "z": "cb95299c.2817c8", + "name": "", + "rules": [ + { + "t": "set", + "p": "obj_counter", + "pt": "flow", + "to": "0", + "tot": "num" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 1010, + "y": 840, + "wires": [ + [ + "fa3b7929.ac7da8", + "9d53dbe2.dbffe8" + ] + ] + }, + { + "id": "6bce0f60.f48998", + "type": "link in", + "z": "cb95299c.2817c8", + "name": "", + "links": [ + "596fc9d4.46c75" + ], + "x": 855, + "y": 840, + "wires": [ + [ + "4f3f7c4a.cb21c4", + "ac439738.28bc18" + ] + ] + }, + { + "id": "596fc9d4.46c75", + "type": "link out", + "z": "cb95299c.2817c8", + "name": "", + "links": [ + "6bce0f60.f48998" + ], + "x": 715, + "y": 500, + "wires": [] + }, + { + "id": "c72a1064.1ec388", + "type": "change", + "z": "cb95299c.2817c8", + "name": "Reset counters", + "rules": [ + { + "t": "set", + "p": "topic", + "pt": "msg", + "to": "object count", + "tot": "str" + }, + { + "t": "set", + "p": "payload", + "pt": "msg", + "to": "0", + "tot": "num" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 600, + "y": 500, + "wires": [ + [ + "596fc9d4.46c75" + ] + ] + }, + { + "id": "9367534a.fb8568", + "type": "ui_multistate_switch", + "z": "cb95299c.2817c8", + "name": "Ecotaxa archive", + "group": "abeb6dad.635a2", + "order": 4, + "width": 5, + "height": 1, + "label": "Ecotaxa archive", + "stateField": "payload", + "enableField": "enable", + "rounded": true, + "useThemeColors": true, + "hideSelectedLabel": false, + "options": [ + { + "label": "No", + "value": "0", + "valueType": "num", + "color": "#009933" + }, + { + "label": "Yes", + "value": "1", + "valueType": "num", + "color": "#999999" + } + ], + "x": 1080, + "y": 220, + "wires": [ + [ + "25ac4f9a.5b05c8" + ] + ] + }, + { + "id": "25ac4f9a.5b05c8", + "type": "change", + "z": "cb95299c.2817c8", + "name": "", + "rules": [ + { + "t": "set", + "p": "ecotaxa", + "pt": "flow", + "to": "payload", + "tot": "msg" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 1280, + "y": 240, + "wires": [ + [] + ] + }, + { + "id": "bef78886.8f0b98", + "type": "inject", + "z": "cb95299c.2817c8", + "name": "1", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "1", + "payloadType": "num", + "x": 910, + "y": 240, + "wires": [ + [ + "9367534a.fb8568", + "25ac4f9a.5b05c8" + ] + ] + }, + { + "id": "32465a4e.8fcac6", + "type": "ui_multistate_switch", + "z": "cb95299c.2817c8", + "name": "Keep objects", + "group": "abeb6dad.635a2", + "order": 5, + "width": 5, + "height": 1, + "label": "Keep objects", + "stateField": "payload", + "enableField": "enable", + "rounded": true, + "useThemeColors": true, + "hideSelectedLabel": false, + "options": [ + { + "label": "No", + "value": "0", + "valueType": "num", + "color": "#009933" + }, + { + "label": "Yes", + "value": "1", + "valueType": "num", + "color": "#999999" + } + ], + "x": 1070, + "y": 300, + "wires": [ + [ + "8dd6f57f.b77f98" + ] + ] + }, + { + "id": "8dd6f57f.b77f98", + "type": "change", + "z": "cb95299c.2817c8", + "name": "", + "rules": [ + { + "t": "set", + "p": "keep", + "pt": "flow", + "to": "payload", + "tot": "msg" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 1270, + "y": 280, + "wires": [ + [] + ] + }, + { + "id": "8090df89.c029e", + "type": "inject", + "z": "cb95299c.2817c8", + "name": "0", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "0", + "payloadType": "num", + "x": 910, + "y": 280, + "wires": [ + [ + "32465a4e.8fcac6", + "8dd6f57f.b77f98" + ] + ] } ] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index fafae92..261501f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ adafruit-circuitpython-motorkit~=1.6.2 Adafruit-SSD1306~=1.6.2 Adafruit-PlatformDetect~=3.12.0 paho-mqtt~=1.5.1 +numpy~=1.20.3 loguru~=0.5.3 picamera~=1.13 picamerax~=20.9.1 diff --git a/scripts/main.py b/scripts/main.py index 5ba8dd4..3803a05 100644 --- a/scripts/main.py +++ b/scripts/main.py @@ -120,7 +120,7 @@ if __name__ == "__main__": stepper_thread.start() # Starts the imager control process - logger.info("Starting the imager control process (step 3/6)") + logger.info("Starting the imager control process (step 3/4)") try: imager_thread = planktoscope.imager.ImagerProcess(shutdown_event) except: @@ -131,10 +131,11 @@ if __name__ == "__main__": # Starts the segmenter process logger.info("Starting the segmenter control process (step 4/4)") - segmenter_thread = planktoscope.segmenter.SegmenterProcess(shutdown_event) + segmenter_thread = planktoscope.segmenter.SegmenterProcess( + shutdown_event, "/home/pi/data" + ) segmenter_thread.start() - # Starts the module process # Uncomment here as needed # logger.info("Starting the module process") @@ -177,4 +178,4 @@ if __name__ == "__main__": # Uncomment this for clean shutdown # module_thread.close() display.stop() - logger.info("Bye") \ No newline at end of file + logger.info("Bye") diff --git a/scripts/planktoscope/segmenter/__init__.py b/scripts/planktoscope/segmenter/__init__.py new file mode 100644 index 0000000..2ee78bc --- /dev/null +++ b/scripts/planktoscope/segmenter/__init__.py @@ -0,0 +1,886 @@ +################################################################################ +# Practical Libraries +################################################################################ + +# Logger library compatible with multiprocessing +from loguru import logger + +# Library to get date and time for folder name and filename +import datetime + +# Library to be able to sleep for a given duration +import time + +# Libraries manipulate json format, execute bash commands +import json, shutil, os + +# Library for starting processes +import multiprocessing + +import io + +import threading +import functools + +# Basic planktoscope libraries +import planktoscope.mqtt +import planktoscope.segmenter.operations +import planktoscope.segmenter.encoder +import planktoscope.segmenter.streamer +import planktoscope.segmenter.ecotaxa + + +################################################################################ +# Morphocut Libraries +################################################################################ +# import morphocut +# import morphocut.file +# import morphocut.image +# import morphocut.stat +# import morphocut.stream +# import morphocut.str +# import morphocut.contrib.zooprocess + +################################################################################ +# Other image processing Libraries +################################################################################ +import skimage.util +import skimage.transform +import skimage.measure +import cv2 +import scipy.stats +import numpy as np +import PIL.Image +import math + + +logger.info("planktoscope.segmenter is loaded") + + +################################################################################ +# Main Segmenter class +################################################################################ +class SegmenterProcess(multiprocessing.Process): + """This class contains the main definitions for the segmenter of the PlanktoScope""" + + @logger.catch + def __init__(self, event, data_path): + """Initialize the Segmenter class + + Args: + event (multiprocessing.Event): shutdown event + """ + super(SegmenterProcess, self).__init__(name="segmenter") + + logger.info("planktoscope.segmenter is initialising") + + self.stop_event = event + self.__pipe = None + self.segmenter_client = None + # Where captured images are saved + self.__img_path = os.path.join(data_path, "img/") + # To save export folders + self.__export_path = os.path.join(data_path, "export/") + # To save objects to export + self.__objects_root = os.path.join(data_path, "objects/") + # To save debug masks + self.__debug_objects_root = os.path.join(data_path, "clean/") + self.__ecotaxa_path = os.path.join(self.__export_path, "ecotaxa") + self.__global_metadata = None + # path for current folder being segmented + self.__working_path = "" + # combination of self.__objects_root and actual sample folder name + self.__working_obj_path = "" + # combination of self.__ecotaxa_path and actual sample folder name + self.__working_ecotaxa_path = "" + # combination of self.__debug_objects_root and actual sample folder name + self.__working_debug_path = "" + self.__archive_fn = "" + self.__flat = None + self.__mask_array = None + self.__mask_to_remove = None + self.__save_debug_img = True + + # create all base path + for path in [ + self.__ecotaxa_path, + self.__objects_root, + self.__debug_objects_root, + ]: + if not os.path.exists(path): + # create the path! + os.makedirs(path) + + logger.success("planktoscope.segmenter is initialised and ready to go!") + + def _find_files(self, path, extension): + for _, _, filenames in os.walk(path, topdown=True): + if filenames: + filenames = sorted(filenames) + return [fn for fn in filenames if fn.endswith(extension)] + + def _manual_median(self, array_of_5): + array_of_5.sort(axis=0) + return array_of_5[2] + + def _save_image(self, image, path): + PIL.Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)).save(path) + + def _save_mask(self, mask, path): + PIL.Image.fromarray(mask).save(path) + + def _calculate_flat(self, images_list, images_number, images_root_path): + # TODO make this calculation optional if a flat already exists + # make sure image number is smaller than image list + if images_number > len(images_list): + logger.error( + "The image number can't be bigger than the lenght of the provided list!" + ) + images_number = len(images_list) + + logger.debug("Opening images") + # start = time.monotonic() + # Read images and build array + images_array = np.array( + [ + cv2.imread( + os.path.join(images_root_path, images_list[i]), + ) + for i in range(images_number) + ] + ) + + # logger.debug(time.monotonic() - start) + logger.success("Opening images") + + logger.info("Manual median calc") + # start = time.monotonic() + + self.__flat = self._manual_median(images_array) + # self.__flat = _numpy_median(images_array) + + # logger.debug(time.monotonic() - start) + + logger.success("Manual median calc") + + # cv2.imshow("flat_color", self.__flat.astype("uint8")) + # cv2.waitKey(0) + + return self.__flat + + def _open_and_apply_flat(self, filepath, flat_ref): + logger.info("Opening images") + start = time.monotonic() + # logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + # Read images + image = cv2.imread(filepath) + # print(image) + + # logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + # logger.debug(time.monotonic() - start) + logger.success("Opening images") + + logger.info("Flat calc") + # start = time.monotonic() + # logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + + # Correct image + image = image / self.__flat + + # adding one black pixel top left + image[0][0] = [0, 0, 0] + + # logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + # logger.debug(time.monotonic() - start) + + image = skimage.exposure.rescale_intensity( + image, in_range=(0, 1.04), out_range="uint8" + ) + # logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + logger.debug(time.monotonic() - start) + logger.success("Flat calc") + + # cv2.imshow("img", img.astype("uint8")) + # cv2.waitKey(0) + if self.__save_debug_img: + self._save_image( + image, + os.path.join(self.__working_debug_path, "cleaned_image.jpg"), + ) + return image + + def _create_mask(self, img, debug_saving_path): + logger.info("Starting the mask creation") + + pipeline = [ + "adaptative_threshold", + "remove_previous_mask", + "erode", + "dilate", + "close", + "erode2", + ] + + mask = img + + for i, transformation in enumerate(pipeline): + function = getattr( + planktoscope.segmenter.operations, transformation + ) # Retrieves the actual operation + mask = function(mask) + + # cv2.imshow(f"mask {transformation}", mask) + # cv2.waitKey(0) + if self.__save_debug_img: + PIL.Image.fromarray(mask).save( + os.path.join(debug_saving_path, f"mask_{i}_{transformation}.jpg") + ) + + logger.success("Mask created") + return mask + + def _get_color_info(self, bgr_img, mask): + # bgr_mean, bgr_stddev = cv2.meanStdDev(bgr_img, mask=mask) + # (b_channel, g_channel, r_channel) = cv2.split(bgr_img) + quartiles = [0, 0.05, 0.25, 0.50, 0.75, 0.95, 1] + # b_quartiles = np.quantile(b_channel, quartiles) + # g_quartiles = np.quantile(g_channel, quartiles) + # r_quartiles = np.quantile(r_channel, quartiles) + hsv_img = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2HSV) + (h_channel, s_channel, v_channel) = cv2.split(hsv_img) + # hsv_mean, hsv_stddev = cv2.meanStdDev(hsv_img, mask=mask) + h_mean = np.mean(h_channel, where=mask) + s_mean = np.mean(s_channel, where=mask) + v_mean = np.mean(v_channel, where=mask) + h_stddev = np.std(h_channel, where=mask) + s_stddev = np.std(s_channel, where=mask) + v_stddev = np.std(v_channel, where=mask) + # TODO Add skewness and kurtosis calculation (with scipy) here + # using https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.skew.html#scipy.stats.skew + # and https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.kurtosis.html#scipy.stats.kurtosis + # h_quartiles = np.quantile(h_channel, quartiles) + # s_quartiles = np.quantile(s_channel, quartiles) + # v_quartiles = np.quantile(v_channel, quartiles) + return { + # "object_MeanRedLevel": bgr_mean[2][0], + # "object_MeanGreenLevel": bgr_mean[1][0], + # "object_MeanBlueLevel": bgr_mean[0][0], + # "object_StdRedLevel": bgr_stddev[2][0], + # "object_StdGreenLevel": bgr_stddev[1][0], + # "object_StdBlueLevel": bgr_stddev[0][0], + # "object_minRedLevel": r_quartiles[0], + # "object_Q05RedLevel": r_quartiles[1], + # "object_Q25RedLevel": r_quartiles[2], + # "object_Q50RedLevel": r_quartiles[3], + # "object_Q75RedLevel": r_quartiles[4], + # "object_Q95RedLevel": r_quartiles[5], + # "object_maxRedLevel": r_quartiles[6], + # "object_minGreenLevel": g_quartiles[0], + # "object_Q05GreenLevel": g_quartiles[1], + # "object_Q25GreenLevel": g_quartiles[2], + # "object_Q50GreenLevel": g_quartiles[3], + # "object_Q75GreenLevel": g_quartiles[4], + # "object_Q95GreenLevel": g_quartiles[5], + # "object_maxGreenLevel": g_quartiles[6], + # "object_minBlueLevel": b_quartiles[0], + # "object_Q05BlueLevel": b_quartiles[1], + # "object_Q25BlueLevel": b_quartiles[2], + # "object_Q50BlueLevel": b_quartiles[3], + # "object_Q75BlueLevel": b_quartiles[4], + # "object_Q95BlueLevel": b_quartiles[5], + # "object_maxBlueLevel": b_quartiles[6], + "MeanHue": h_mean, + "MeanSaturation": s_mean, + "MeanValue": v_mean, + "StdHue": h_stddev, + "StdSaturation": s_stddev, + "StdValue": v_stddev, + # "object_minHue": h_quartiles[0], + # "object_Q05Hue": h_quartiles[1], + # "object_Q25Hue": h_quartiles[2], + # "object_Q50Hue": h_quartiles[3], + # "object_Q75Hue": h_quartiles[4], + # "object_Q95Hue": h_quartiles[5], + # "object_maxHue": h_quartiles[6], + # "object_minSaturation": s_quartiles[0], + # "object_Q05Saturation": s_quartiles[1], + # "object_Q25Saturation": s_quartiles[2], + # "object_Q50Saturation": s_quartiles[3], + # "object_Q75Saturation": s_quartiles[4], + # "object_Q95Saturation": s_quartiles[5], + # "object_maxSaturation": s_quartiles[6], + # "object_minValue": v_quartiles[0], + # "object_Q05Value": v_quartiles[1], + # "object_Q25Value": v_quartiles[2], + # "object_Q50Value": v_quartiles[3], + # "object_Q75Value": v_quartiles[4], + # "object_Q95Value": v_quartiles[5], + # "object_maxValue": v_quartiles[6], + } + + def _extract_metadata_from_regionprop(self, prop): + return { + "label": prop.label, + # width of the smallest rectangle enclosing the object + "width": prop.bbox[3] - prop.bbox[1], + # height of the smallest rectangle enclosing the object + "height": prop.bbox[2] - prop.bbox[0], + # X coordinates of the top left point of the smallest rectangle enclosing the object + "bx": prop.bbox[1], + # Y coordinates of the top left point of the smallest rectangle enclosing the object + "by": prop.bbox[0], + # circularity : (4∗π ∗Area)/Perim^2 a value of 1 indicates a perfect circle, a value approaching 0 indicates an increasingly elongated polygon + "circ.": (4 * np.pi * prop.filled_area) / prop.perimeter ** 2, + # Surface area of the object excluding holes, in square pixels (=Area*(1-(%area/100)) + "area_exc": prop.area, + # Surface area of the object in square pixels + "area": prop.filled_area, + # Percentage of object’s surface area that is comprised of holes, defined as the background grey level + "%area": 1 - (prop.area / prop.filled_area), + # Primary axis of the best fitting ellipse for the object + "major": prop.major_axis_length, + # Secondary axis of the best fitting ellipse for the object + "minor": prop.minor_axis_length, + # Y position of the center of gravity of the object + "y": prop.centroid[0], + # X position of the center of gravity of the object + "x": prop.centroid[1], + # The area of the smallest polygon within which all points in the objet fit + "convex_area": prop.convex_area, + # # Minimum grey value within the object (0 = black) + # "min": prop.min_intensity, + # # Maximum grey value within the object (255 = white) + # "max": prop.max_intensity, + # # Average grey value within the object ; sum of the grey values of all pixels in the object divided by the number of pixels + # "mean": prop.mean_intensity, + # # Integrated density. The sum of the grey values of the pixels in the object (i.e. = Area*Mean) + # "intden": prop.filled_area * prop.mean_intensity, + # The length of the outside boundary of the object + "perim.": prop.perimeter, + # major/minor + "elongation": np.divide(prop.major_axis_length, prop.minor_axis_length), + # max-min + # "range": prop.max_intensity - prop.min_intensity, + # perim/area_exc + "perimareaexc": prop.perimeter / prop.area, + # perim/major + "perimmajor": prop.perimeter / prop.major_axis_length, + # (4 ∗ π ∗ Area_exc)/perim 2 + "circex": np.divide(4 * np.pi * prop.area, prop.perimeter ** 2), + # Angle between the primary axis and a line parallel to the x-axis of the image + "angle": prop.orientation / np.pi * 180 + 90, + # # X coordinate of the top left point of the image + # 'xstart': data_object['raw_img']['meta']['xstart'], + # # Y coordinate of the top left point of the image + # 'ystart': data_object['raw_img']['meta']['ystart'], + # Maximum feret diameter, i.e. the longest distance between any two points along the object boundary + # 'feret': data_object['raw_img']['meta']['feret'], + # feret/area_exc + # 'feretareaexc': data_object['raw_img']['meta']['feret'] / property.area, + # perim/feret + # 'perimferet': property.perimeter / data_object['raw_img']['meta']['feret'], + "bounding_box_area": prop.bbox_area, + "eccentricity": prop.eccentricity, + "equivalent_diameter": prop.equivalent_diameter, + "euler_number": prop.euler_number, + "extent": prop.extent, + "local_centroid_col": prop.local_centroid[1], + "local_centroid_row": prop.local_centroid[0], + "solidity": prop.solidity, + } + + def _stream(self, img): + img_object = io.BytesIO() + PIL.Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)).save( + img_object, format="JPEG" + ) + logger.debug("Sending the object in the pipe!") + planktoscope.segmenter.streamer.sender.send(img_object) + + def _slice_image(self, img, name, mask, start_count=0): + """Slice a given image using give mask + + Args: + img (img array): Image to slice + name (string): name of the original image + mask (mask binary array): mask to use slice with + start_count (int, optional): count start to number the objects, so each one is unique. Defaults to 0. + + Returns: + tuple: (Number of saved objects, original number of objects before size filtering) + """ + # TODO retrieve here all those from the global metadata + minESD = 40 # microns + minArea = math.pi * (minESD / 2) * (minESD / 2) + pixel_size = 1.01 # to be retrieved from metadata + # minsizepix = minArea / pixel_size / pixel_size + minsizepix = (minESD / pixel_size) ** 2 + + labels, nlabels = skimage.measure.label(mask, return_num=True) + regionprops = skimage.measure.regionprops(labels) + regionprops_filtered = [ + region for region in regionprops if region.bbox_area >= minsizepix + ] + object_number = len(regionprops_filtered) + logger.debug(f"Found {nlabels} labels, or {object_number} after size filtering") + + for (i, region) in enumerate(regionprops_filtered): + region.label = i + start_count + + # Publish the object_id to via MQTT to Node-RED + self.segmenter_client.client.publish( + "status/segmenter/object_id", + f'{{"object_id":"{region.label}"}}', + ) + obj_image = img[region.slice] + object_id = f"{name}_{i}" + object_fn = os.path.join(self.__working_obj_path, f"{object_id}.jpg") + self._save_image(obj_image, object_fn) + self._stream(obj_image) + + if self.__save_debug_img: + self._save_mask( + region.filled_image, + os.path.join(self.__working_debug_path, f"obj_{i}_mask.jpg"), + ) + + colors = self._get_color_info(obj_image, region.filled_image) + metadata = self._extract_metadata_from_regionprop(region) + + object_metadata = { + "name": f"{object_id}", + "metadata": {**metadata, **colors}, + } + + # publish metrics about the found object + self.segmenter_client.client.publish( + "status/segmenter/metric", + json.dumps( + object_metadata, cls=planktoscope.segmenter.encoder.NpEncoder + ), + ) + + if "objects" in self.__global_metadata: + self.__global_metadata["objects"].append(object_metadata) + else: + self.__global_metadata.update({"objects": [object_metadata]}) + + # TODO make the TSV for ecotaxa + if self.__save_debug_img: + if object_number: + for region in regionprops_filtered: + tagged_image = cv2.drawMarker( + img, + (int(region.centroid[1]), int(region.centroid[0])), + (0, 0, 255), + cv2.MARKER_CROSS, + ) + tagged_image = cv2.rectangle( + img, + pt1=region.bbox[-3:-5:-1], + pt2=region.bbox[-1:-3:-1], + color=(150, 0, 200), + thickness=1, + ) + + # contours = [region.bbox for region in regionprops_filtered] + # for contour in contours: + # tagged_image = cv2.rectangle( + # img, pt1=(contours[0][1],contours[0][0]), pt2=(contours[0][3],contours[0][2]), color=(0, 0, 255), thickness=2 + # ) + # contours = [region.coords for region in regionprops_filtered] + # for contour in contours: + # tagged_image = cv2.drawContours( + # img_erode_2, contour, -1, color=(0, 0, 255), thickness=2 + # ) + + # cv2.imshow("tagged_image", tagged_image.astype("uint8")) + # cv2.waitKey(0) + self._save_image( + tagged_image, + os.path.join(self.__working_debug_path, "tagged.jpg"), + ) + else: + self._save_image( + img, + os.path.join(self.__working_debug_path, "tagged.jpg"), + ) + return (object_number, len(regionprops)) + + def _pipe(self): + logger.info("Finding images") + images_list = self._find_files( + self.__working_path, ("JPG", "jpg", "JPEG", "jpeg") + ) + + logger.debug(f"Images found are {images_list}") + images_count = len(images_list) + logger.debug(f"We found {images_count} images, good luck!") + + first_start = time.monotonic() + self.__mask_to_remove = None + average = 0 + total_objects = 0 + average_objects = 0 + recalculate_flat = True + # TODO check image list here to find if a flat exists + # we recalculate the flat every 10 pictures + if recalculate_flat: + self.segmenter_client.client.publish( + "status/segmenter", '{"status":"Calculating flat"}' + ) + if images_count < 10: + self._calculate_flat( + images_list[0:images_count], images_count, self.__working_path + ) + else: + self._calculate_flat(images_list[0:10], 10, self.__working_path) + recalculate_flat = False + + if self.__save_debug_img: + self._save_image( + self.__flat, + os.path.join(self.__working_debug_path, "flat_color.jpg"), + ) + + average_time = 0 + + # TODO here would be a good place to parallelize the computation + for (i, filename) in enumerate(images_list): + name = os.path.splitext(filename)[0] + + # Publish the object_id to via MQTT to Node-RED + self.segmenter_client.client.publish( + "status/segmenter", f'{{"status":"Segmenting image {filename}"}}' + ) + + # we recalculate the flat if the heuristics detected we should + if recalculate_flat: # not i % 10 and i < (images_count - 10) + if i > (len(images_list) - 11): + # We are too close to the end of the list, take the previous 10 images instead of the next 10 + flat = self._calculate_flat( + images_list[i - 10 : i], 10, self.__working_path + ) + else: + flat = self._calculate_flat( + images_list[i : i + 10], 10, self.__working_path + ) + recalculate_flat = False + if self.__save_debug_img: + self._save_image( + self.__flat, + os.path.join( + os.path.dirname(self.__working_debug_path), + f"flat_color_{i}.jpg", + ), + ) + + self.__working_debug_path = os.path.join( + self.__debug_objects_root, + self.__working_path.split(self.__img_path)[1].strip(), + name, + ) + + logger.debug(f"The debug objects path is {self.__working_debug_path}") + # Create the debug objects path if needed + if self.__save_debug_img and not os.path.exists(self.__working_debug_path): + # create the path! + os.makedirs(self.__working_debug_path) + + start = time.monotonic() + logger.info(f"Starting work on {name}, image {i+1}/{images_count}") + + img = self._open_and_apply_flat( + os.path.join(self.__working_path, images_list[i]), self.__flat + ) + + # logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + # logger.debug(time.monotonic() - start) + + # start = time.monotonic() + # logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + + mask = self._create_mask(img, self.__working_debug_path) + + # logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + # logger.debug(time.monotonic() - start) + + # start = time.monotonic() + # logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + + objects_count, _ = self._slice_image(img, name, mask, total_objects) + total_objects += objects_count + # Simple heuristic to detect a movement of the flow cell and a change in the resulting flat + if objects_count > average_objects + 20: + logger.debug( + f"We need to recalculate a flat since we have {objects_count} new objects instead of the average of {average_objects}" + ) + recalculate_flat = True + average_objects = (average_objects * i + objects_count) / (i + 1) + + # logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + # logger.debug(time.monotonic() - start) + delay = time.monotonic() - start + average_time = (average_time * i + delay) / (i + 1) + logger.success( + f"Work on {name} is OVER! Done in {delay}s, average time is {average_time}s, average number of objects is {average_objects}" + ) + logger.success( + f"We also found {objects_count} objects in this image, at a rate of {objects_count / delay} objects per second" + ) + logger.success(f"So far we found {total_objects} objects") + + total_duration = (time.monotonic() - first_start) / 60 + logger.success( + f"{images_count} images done in {total_duration} minutes, or an average of {average_time}s per image or {total_duration*60/images_count}s per image" + ) + logger.success( + f"We also found {total_objects} objects, or an average of {total_objects / (total_duration * 60)}objects per second" + ) + + planktoscope.segmenter.ecotaxa.ecotaxa_export( + self.__archive_fn, + self.__global_metadata, + self.__working_obj_path, + keep_files=True, + ) + + # cleanup + # we're done free some mem + self.__flat = None + + def segment_all(self, paths: list): + """Starts the segmentation in all the folders given recursively + + Args: + paths (list, optional): path list to recursively explore. Defaults to [self.__img_path]. + """ + img_paths = [] + for path in paths: + for x in os.walk(path): + if x[0] not in img_paths: + img_paths.append(x[0]) + self.segment_list(img_paths) + + def segment_list(self, path_list: list, force=True): + """Starts the segmentation in the folders given + + Args: + path_list (list): [description] + """ + logger.info(f"The pipeline will be run in {len(path_list)} directories") + logger.debug(f"Those are {path_list}") + for path in path_list: + logger.debug(f"{path}: Checking for the presence of metadata.json") + if os.path.exists(os.path.join(path, "metadata.json")): + # The file exists, let's check if we force or not + if force: + # forcing, let's gooooo + if not self.segment_path(path): + logger.error(f"There was en error while segmenting {path}") + else: + # we need to check for the presence of done.txt in each folder + logger.debug(f"{path}: Checking for the presence of done.txt") + if os.path.exists(os.path.join(path, "done.txt")): + logger.debug( + f"Moving to the next folder, {path} has already been segmented" + ) + else: + if not self.segment_path(path): + logger.error(f"There was en error while segmenting {path}") + else: + logger.debug(f"Moving to the next folder, {path} has no metadata.json") + # Publish the status "Done" to via MQTT to Node-RED + self.segmenter_client.client.publish("status/segmenter", '{"status":"Done"}') + + def segment_path(self, path): + """Starts the segmentation in the given path + + Args: + path (string): path of folder to do segmentation in + """ + logger.info(f"Loading the metadata file for {path}") + with open(os.path.join(path, "metadata.json"), "r") as config_file: + self.__global_metadata = json.load(config_file) + logger.debug(f"Configuration loaded is {self.__global_metadata}") + + # Remove all the key,value pairs that don't start with acq, sample, object or process (for Ecotaxa) + self.__global_metadata = dict( + filter( + lambda item: item[0].startswith(("acq", "sample", "object", "process")), + self.__global_metadata.items(), + ) + ) + + project = self.__global_metadata["sample_project"].replace(" ", "_") + date = datetime.datetime.utcnow().isoformat() + sample = self.__global_metadata["sample_id"].replace(" ", "_") + + # TODO Add process informations to metadata here + + # Define the name of the .zip file that will contain the images and the .tsv table for EcoTaxa + self.__archive_fn = os.path.join( + self.__ecotaxa_path, + # filename includes project name, timestamp and sample id + f"export_{project}_{date}_{sample}.zip", + ) + + self.__working_path = path + + # recreate the subfolder img architecture of this folder inside objects + # when we split the working path with the base img path, we get the date/sample architecture back + # os.path.relpath("/home/pi/data/img/2020-10-17/5/5","/home/pi/data/img/") => '2020-10-17/5/5' + + sample_path = os.path.relpath(self.__working_path, self.__img_path) + + logger.debug(f"base obj path is {self.__objects_root}") + logger.debug(f"sample path is {sample_path}") + + self.__working_obj_path = os.path.join(self.__objects_root, sample_path) + + logger.debug(f"The working objects path is {self.__working_obj_path}") + + self.__working_debug_path = os.path.join(self.__debug_objects_root, sample_path) + + logger.debug(f"The debug objects path is {self.__working_debug_path}") + + # Create the paths + for path in [self.__working_obj_path, self.__working_debug_path]: + if not os.path.exists(path): + # create the path! + os.makedirs(path) + + logger.debug(f"The archive folder is {self.__archive_fn}") + + logger.info(f"Starting the pipeline in {path}") + + try: + self._pipe() + except Exception as e: + logger.exception(f"There was an error in the pipeline {e}") + return False + + # Add file 'done' to path to mark the folder as already segmented + with open(os.path.join(self.__working_path, "done.txt"), "w") as done_file: + done_file.writelines(datetime.datetime.utcnow().isoformat()) + logger.info(f"Pipeline has been run for {path}") + return True + + @logger.catch + def treat_message(self): + last_message = {} + if self.segmenter_client.new_message_received(): + logger.info("We received a new message") + last_message = self.segmenter_client.msg["payload"] + logger.debug(last_message) + self.segmenter_client.read_message() + + if "action" in last_message: + # If the command is "segment" + if last_message["action"] == "segment": + path = None + recursive = True + force = False + # {"action":"segment"} + if "settings" in last_message: + if "force" in last_message["settings"]: + # force rework of already done folder + force = last_message["settings"]["force"] + if "recursive" in last_message["settings"]: + # parse folders recursively starting from the given parameter + recursive = last_message["settings"]["recursive"] + # TODO eventually add customisation to segmenter parameters here + + if "path" in last_message: + path = last_message["path"] + + # Publish the status "Started" to via MQTT to Node-RED + self.segmenter_client.client.publish( + "status/segmenter", '{"status":"Started"}' + ) + if path: + if recursive: + self.segment_all(path) + else: + self.segment_list(path) + else: + self.segment_all(self.__img_path) + + elif last_message["action"] == "stop": + logger.info("The segmentation has been interrupted.") + + # Publish the status "Interrupted" to via MQTT to Node-RED + self.segmenter_client.client.publish( + "status/segmenter", '{"status":"Interrupted"}' + ) + + elif last_message["action"] == "update_config": + logger.error( + "We can't update the configuration while we are segmenting." + ) + + # Publish the status "Interrupted" to via MQTT to Node-RED + self.segmenter_client.client.publish( + "status/segmenter", '{"status":"Busy"}' + ) + + elif last_message["action"] != "": + logger.warning( + f"We did not understand the received request {action} - {last_message}" + ) + + ################################################################################ + # While loop for capturing commands from Node-RED + ################################################################################ + @logger.catch + def run(self): + """This is the function that needs to be started to create a thread""" + logger.info( + f"The segmenter control thread has been started in process {os.getpid()}" + ) + + # MQTT Service connection + self.segmenter_client = planktoscope.mqtt.MQTT_Client( + topic="segmenter/#", name="segmenter_client" + ) + + # Publish the status "Ready" to via MQTT to Node-RED + self.segmenter_client.client.publish("status/segmenter", '{"status":"Ready"}') + + logger.info("Setting up the streaming server thread") + address = ("", 8001) + fps = 0.5 + refresh_delay = 3 # was 1/fps + handler = functools.partial( + planktoscope.segmenter.streamer.StreamingHandler, refresh_delay + ) + server = planktoscope.segmenter.streamer.StreamingServer(address, handler) + self.streaming_thread = threading.Thread( + target=server.serve_forever, daemon=True + ) + # start streaming only when needed + self.streaming_thread.start() + + logger.success("Segmenter is READY!") + + # This is the loop + while not self.stop_event.is_set(): + self.treat_message() + time.sleep(0.5) + + logger.info("Shutting down the segmenter process") + planktoscope.segmenter.streamer.sender.close() + self.segmenter_client.client.publish("status/segmenter", '{"status":"Dead"}') + self.segmenter_client.shutdown() + logger.success("Segmenter process shut down! See you!") + + +# This is called if this script is launched directly +if __name__ == "__main__": + # TODO This should be a test suite for this library + segmenter_thread = SegmenterProcess( + None, "/home/rbazile/Documents/pro/PlanktonPlanet/Planktonscope/Segmenter/data/" + ) + segmenter_thread.segment_path( + "/home/rbazile/Documents/pro/PlanktonPlanet/Planktonscope/Segmenter/data/test" + ) diff --git a/scripts/planktoscope/segmenter/ecotaxa.py b/scripts/planktoscope/segmenter/ecotaxa.py new file mode 100644 index 0000000..21ea751 --- /dev/null +++ b/scripts/planktoscope/segmenter/ecotaxa.py @@ -0,0 +1,250 @@ +# Logger library compatible with multiprocessing +from loguru import logger + + +import pandas +import morphocut.contrib.ecotaxa +import zipfile +import os +import io + +""" +Example of metadata file received +{ + "sample_project": "Tara atlantique sud 2021", + "sample_id": "Tara atlantique sud 2021_hsn_2021_01_22", + "sample_ship": "TARA", + "sample_operator": "DAVE", + "sample_sampling_gear": "net_hsn", + "sample_concentrated_sample_volume": 100, + "acq_id": "Tara atlantique sud 2021_hsn_2021_01_22_1", + "acq_instrument": "PlanktonScope v2.2", + "acq_instrument_id": "Babane Batoukoa", + "acq_celltype": 300, + "acq_minimum_mesh": 20, + "acq_maximum_mesh": 200, + "acq_volume": "37.50", + "acq_imaged_volume": "2.9320", + "acq_fnumber_objective": 16, + "acq_camera": "HQ Camera", + "acq_nb_frame": 750, + "acq_software": "PlanktoScope v2.2-cd03960", + "object_date": "20210122", + "object_time": "115300", + "object_lat": "-21.6167", + "object_lon": "-38.2667", + "object_depth_min": 0, + "object_depth_max": 1, + "process_pixel": 1, + "process_id": 1, + "sample_gear_net_opening": 40, + "object_date_end": "20210122", + "object_time_end": "115800", + "object_lat_end": "-21.6168", + "object_lon_end": "-38.2668", + "sample_total_volume": 0.019, + "acq_local_datetime": "2020-12-28T01:03:38", + "acq_camera_resolution": "4056 x 3040", + "acq_camera_iso": 100, + "acq_camera_shutter_speed": 1, + "acq_uuid": "Pobolautoa Jouroacu Yepaoyoa Babane Batoukoa", + "sample_uuid": "Pobo Raikoajou Roacuye Sune Babane Batoukoa", + "objects": [ + { + "name": "01_13_28_232066_0", + "metadata": { + "label": 0, + "width": 29, + "height": 80, + "bx": 3566, + "by": 558, + "circ.": 0.23671615936018325, + "area_exc": 1077, + "area": 1164, + "%area": 0.07474226804123707, + "major": 84.35144817947639, + "minor": 22.651130623883205, + "y": 596.041782729805, + "x": 3581.5199628597957, + "convex_area": 1652, + "perim.": 248.58073580374352, + "elongation": 3.7239398589020882, + "perimareaexc": 0.23080848264043038, + "perimmajor": 2.9469646481330463, + "circex": 0.21902345672759224, + "angle": 87.22379495121363, + "bounding_box_area": 2320, + "eccentricity": 0.9632705408870905, + "equivalent_diameter": 37.03078435139837, + "euler_number": 0, + "extent": 0.4642241379310345, + "local_centroid_col": 15.51996285979573, + "local_centroid_row": 38.041782729805014, + "solidity": 0.6519370460048426, + "MeanHue": 82.38316151202748, + "MeanSaturation": 51.052405498281786, + "MeanValue": 206.95103092783506, + "StdHue": 59.40613253229589, + "StdSaturation": 33.57478449681238, + "StdValue": 45.56457794758993 + } + }, + { + "name": "01_13_28_232066_1", + "metadata": { + "label": 1, + "width": 632, + "height": 543, + "bx": 2857, + "by": 774, + "circ.": 0.021738961926042914, + "area_exc": 15748, + "area": 15894, + "%area": 0.009185856297974082, + "major": 684.6802239233394, + "minor": 463.2216914254333, + "y": 1018.0638176276352, + "x": 3103.476631953264, + "convex_area": 208154, + "perim.": 3031.113057309706, + "elongation": 1.47808325170704, + "perimareaexc": 0.19247606409129453, + "perimmajor": 4.42704922882231, + "circex": 0.021539270945723152, + "angle": 30.838437768527037, + "bounding_box_area": 343176, + "eccentricity": 0.7363949729430449, + "equivalent_diameter": 141.60147015652535, + "euler_number": -2, + "extent": 0.045888989906054035, + "local_centroid_col": 246.4766319532639, + "local_centroid_row": 244.06381762763525, + "solidity": 0.07565552427529618, + "MeanHue": 66.62765823581226, + "MeanSaturation": 50.187051717629295, + "MeanValue": 192.57524852145463, + "StdHue": 63.69755322016918, + "StdSaturation": 20.599500714199607, + "StdValue": 28.169250980740102 + } + } + ] +} +""" + + +""" +Ecotaxa Export archive format +In a folder place: + +image files + + Colour and 8-bits greyscale images are supported, in jpg, png,gif (possibly animated) formats. +a tsv (TAB separated file) which can be .txt or .tsv extension. File name must start with ecotaxa (ecotaxa*.txt or ecotaxa*.tsv) + + It contains the metadata for each image. This file can be created in a spreadsheet application (see formats and examples below). + + Line 1 contains column headers + Line 2 contains data format codes; [f] for floats, [t] for text + Line 3...n contain data for each image + +The metadata and data for each image is organised in various levels (image, object, process, sample, etc.). All column names must be prefixed with the level name (img_***, object_***, etc.). Some common fields, used to filter data, must be named and sometimes formatted in a certain way (required format in blue), which is documented below. But, overall, the only two mandatory fields are img_file_name and object_id (in red). + + IMAGE + img_file_name [t]: name of the image file in the folder (including extension) + img_rank [f] : rank of image to be displayed, in case of existence of multiple (<10) images for one object. Starts at 1. + OBJECT: one object to be classified, usually one organism. One object can be represented by several images. In this tsv file, there is one line per image which means the object data gets repeated on several lines. + object_id [t] : identifier of the object, must be unique in the project. It will be displayed in the object page + object_link [f] : URL of an associated website + object_lat [f] : latitude, decimal degrees + object_lon [f] : longitude, decimal degrees + object_date [f] : ISO8601 YYYYMMJJ UTC + object_time [f] : ISO8601 HHMMSS UTC + object_depth_min [f] : minimum depth of object, meters + object_depth_max [f] : maximum depth of object, meters + And, for already classified objects + object_annotation_date [t] : ISO8601 YYYYMMJJ UTC + object_annotation_time [t] : ISO8601 YYYYMMJJ UTC + object_annotation_category [t] : class of the object with optionally its direct parent following separated by left angle bracket without whitespace "Cnidaria