diff --git a/docs/README.md b/docs/README.md index ac3464fbc6315c31e3439488f650238bc2b0b081..a19d52025ac5dc598d6e2a867587c439238e00aa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,118 +1,148 @@ -# Mode opératoire -## Présentation -**Cet outil vous accompagne** - pas à pas - dans **l'analyse des photos de blobs**, afin de fournir les données nécessaires à l'apprentissage du _Machine Learning_. - -L'analyse se déroule en **5 étapes** : - -[1. Charger une photo](#Étape-1-charger-une-photo) <br> -[2. Positionner la règle](#Étape-2-positioner-la-règle) <br> -[3. Positionner la boîte de Petri](#Étape-3-positionner-la-boîte-de-petri) <br> -[4. Détourer le blob à main levée](#Étape-4-détourer-le-blob) <br> -[5. Télécharger les fichiers de mesures](#Étape-5-télécharger-les-résultats) <br> - -Chaque étape est décrite dans la partie droite du _lab_. Il est possible de revenir à tout moment -en arrière en cliquant sur le titre de l'étape. - -C'est parti... - ---- - -## Étape 1 : charger une photo - -**Charger une photo** à analyser - située sur l'ordinateur - avec le **bouton "Parcourir..."**. - - - -Une fois la photo chargée, **l'étape 2 est automatiquement activée**. - -Mais avant... - -> ### Comment zoomer et déplacer la photo -> Pour prendre les mesures avec le plus de précision, il est possible de : -> * **zoomer/dézoomer (agrandir/rétrécir) la photo** avec la molette de la souris -> * **déplacer la photo** : tout en appuyant sur la touche contrôle (le curseur devient des flèches), cliquer sur la photo et la déplacer avec la souris -> -> La barre de boutons de zoom sur le haut de la page permet d'ajuster l'affichage : -> *  : agrandir la photo -> *  : rétrécir la photo -> *  : ajuster la photo à l'écran et la repositionner au centre (:sparkles: bien utile si l'on perd la photo de vue) -> *  : afficher la photo en taille réelle - -Maintenant au travail ! - ---- - -## Étape 2 : positioner la règle -Cette étape permet au logiciel de déterminer l'échelle de la photo. - -**Déplacer la ligne jaune à l'aide des 2 "poignées"** (petits carrés blancs) à chacune de ses extrémités afin de -**couvrir 10cm de la règle**. - - - -Pour s'assurer que 10 centimètres sont bien couverts, **les petits points "détrompeurs" doivent tomber sur chaque centimètre**. - - - -Si il y a moins de 10cm règle sur la photo, il est possible de modifier la taille à couvrir avec les boutons +/- (en bleu ci-dessous). -Le nombre de détrompeurs sera ajusté. - -Pour placer la règle avec plus de précision, utiliser le bouton  (en jaune ci-dessous). - - - -Une fois la règle placée, **passer à l'étape suivante en appuyant sur le bouton "Terminé !"** (en vert ci-dessus). - ---- - -## Étape 3 : positionner la boîte de Petri - -Tout comme la règle, **placer la boîte de Petri à l'aide des poignées**, et utiliser le bouton  -pour la placer avec précision. - - - -> -> -> Au début on y va à tâtons, mais on prend vite le coup de main. -> -> - - - -Une fois la boîte de Petri correctement positionnée, **passer à l'étape suivante en appuyant sur "C'est fini !"** - ---- - -## Étape 4 : détourer le blob - -Lors de cette étape, **entourer d'une ligne jaune le blob** (dessiner en maintenant le bouton de la souris pressé). - - - -> -> **Oh non !** Si près de la fin -> ->  ->  -> -> Il est possible de revenir en arrière sur les derniers points du tracé avec le bouton  -> ->  -> - -Lorsque l'on a rejoint le point de départ marqué par un carré blanc, **le contour est terminé et colorié en jaune**. - - - -Le bouton  s'active et permet de **passer à l'étape suivante**. - ---- - -## Étape 5 : télécharger les résultats - - - -**Télécharger les fichiers un à un** avec . - -Les fichiers sont stockés dans le répertoire "Téléchargements" du navigateur, **les transmettre par [la plateforme de dépôt de données](https://blob.cnrs.fr/)**. +# Mode opératoire +## Présentation +**Cet outil vous accompagne** - pas à pas - dans **l'analyse des photos de blobs**, afin de fournir les données nécessaires à l'apprentissage du _Machine Learning_. + +L'analyse se déroule en **5 étapes** : + +[1. Charger une photo](#Étape-1-charger-une-photo) <br> +[2. Positionner la règle](#Étape-2-positioner-la-règle) <br> +[3. Positionner la boîte de Petri](#Étape-3-positionner-la-boîte-de-petri) <br> +[4. Détourer le blob à main levée](#Étape-4-détourer-le-blob) <br> +[5. Télécharger les fichiers de mesures](#Étape-5-télécharger-les-résultats) <br> +[5'. Fusionner les .csv ](#Étape-539-fusionner-les-csv) + +Chaque étape est décrite dans la partie droite du _lab_. Il est possible de revenir à tout moment +en arrière en cliquant sur le titre de l'étape. + +C'est parti... + +--- + +## Étape 1 : charger une photo + +**Charger une photo** à analyser - située sur l'ordinateur - avec le **bouton "Parcourir..."**. + + + +Une fois la photo chargée, **l'étape 2 est automatiquement activée**. + +Mais avant... + +> ### Comment zoomer et déplacer la photo +> Pour prendre les mesures avec le plus de précision, il est possible de : +> * **zoomer/dézoomer (agrandir/rétrécir) la photo** avec la molette de la souris +> * **déplacer la photo** : tout en appuyant sur la touche contrôle (le curseur devient des flèches), cliquer sur la photo et la déplacer avec la souris +> +> La barre de boutons de zoom sur le haut de la page permet d'ajuster l'affichage : +> *  : agrandir la photo +> *  : rétrécir la photo +> *  : ajuster la photo à l'écran et la repositionner au centre (:sparkles: bien utile si l'on perd la photo de vue) +> *  : afficher la photo en taille réelle + +Maintenant au travail ! + +--- + +## Étape 2 : positioner la règle +Cette étape permet au logiciel de déterminer l'échelle de la photo. + +**Déplacer la ligne jaune à l'aide des 2 "poignées"** (petits carrés blancs) à chacune de ses extrémités afin de +**couvrir 10cm de la règle**. + + + +Pour s'assurer que 10 centimètres sont bien couverts, **les petits points "détrompeurs" doivent tomber sur chaque centimètre**. + + + +Si il y a moins de 10cm règle sur la photo, il est possible de modifier la taille à couvrir avec les boutons +/- (en bleu ci-dessous). +Le nombre de détrompeurs sera ajusté. + +Pour placer la règle avec plus de précision, utiliser le bouton  (en jaune ci-dessous). + + + +Une fois la règle placée, **passer à l'étape suivante en appuyant sur le bouton "Terminé !"** (en vert ci-dessus). + +--- + +## Étape 3 : positionner la boîte de Petri + +Tout comme la règle, **placer la boîte de Petri à l'aide des poignées**, et utiliser le bouton  +pour la placer avec précision. + + + +> +> +> Au début on y va à tâtons, mais on prend vite le coup de main. +> +> + + + +Une fois la boîte de Petri correctement positionnée, **passer à l'étape suivante en appuyant sur "C'est fini !"** + +--- + +## Étape 4 : détourer le blob + +Lors de cette étape, **entourer d'une ligne jaune le blob** (dessiner en maintenant le bouton de la souris pressé). + + + +> +> **Oh non !** Si près de la fin +> +>  +>  +> +> Il est possible de revenir en arrière sur les derniers points du tracé avec le bouton  +> +>  +> + +Lorsque l'on a rejoint le point de départ marqué par un carré blanc, **le contour est terminé et colorié en jaune**. + + + +Le bouton  s'active et permet de **passer à l'étape suivante**. + +--- + +## Étape 5 : télécharger les résultats + + + +**Télécharger les fichiers un à un** avec . + +Les fichiers sont stockés dans le répertoire "Téléchargements" du navigateur. + +> [!NOTE|label:Attention] +> Ces fichiers ne peuvent pas être tout de suite déposés sur l'espace de dépôt des fichiers d'analyse (https://blob.cnrs.fr/). +> +> En effet, le fichier _.csv_ généré ne contient que les mesures (aire, périmètre, circualirité, etc.) que pour la photo qui vient d'être analysée. +> Le nom du fichier contient le numéro du blob (par exemple: Results_ConJ1Ex*B3*.csv). +> +> L'espace de dépôt des analyses n'attend qu'**un seul fichier** _.csv_ **pour toutes** les photos d'une séquence groupe/jour/expérience (par ex.: Results_ConJ1Ex.csv). +> +> Une étape supplémentaire permet de regrouper les fichiers. + +## Étape 5' : fusionner les .csv + +> :gift: Pour 5 étapes effectuées une étape offerte ! + +Un bouton dans l'étape 5 permet d'ouvrir l'outil de fusion des fichiers. + + + +Une fenêtre comme celle-ci s'affiche : + + + +Pas à pas : +1. \[encadré en vert] Sélectionner des fichiers à fusionner. Plusieurs fichiers peuvent être sélectionnés à la fois. Ils peuvent être sélectionnés en vrac mêmes s'ils font partie de séquences (groupe/jour/expérience) d'analyse différentes. +2. \[marqué en rouge] Les fichiers sont automatiquement triés et regroupés par séquence (groupe/jour/expérience) +3. \[entouré en jaune] Les fichiers résultats sont téléchargeables avec le bouton  + +Maintenant **les fichiers peuvent être transmis par [la plateforme de dépôt de données](https://blob.cnrs.fr/)**. diff --git a/docs/images/fusion_button.png b/docs/images/fusion_button.png new file mode 100644 index 0000000000000000000000000000000000000000..73790a4a96c3fc6c9ec4fcb496a8b5e7dc36991a Binary files /dev/null and b/docs/images/fusion_button.png differ diff --git a/docs/images/fusion_dialog.png b/docs/images/fusion_dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..7e2094b73fb5553a9d9cce052818f00e7e7aa923 Binary files /dev/null and b/docs/images/fusion_dialog.png differ diff --git a/docs/index.html b/docs/index.html index 7c443aae2a2e303c0dd9a4abad81c41b725d5c1a..ee42b579198eb7861f5822f32f240f0842e76d93 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,20 +1,24 @@ -<!DOCTYPE html> -<html> -<head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>Blob Analysis Lab - Documentation</title> - <link rel="icon" href="favicon.ico" /> - <link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/themes/vue.css" /> -</head> -<body> -<div id="doc">Chargement...</div> -<script> - window.$docsify = { - el: '#doc' - } -</script> -<script src="//cdn.jsdelivr.net/npm/docsify@4"></script> -</body> -</html> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Blob Analysis Lab - Documentation</title> + <link rel="icon" href="favicon.ico" /> + <link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/themes/vue.css" /> +</head> +<body> +<div id="doc">Chargement...</div> +<script> + window.$docsify = { + el: '#doc', + 'flexible-alerts': { + style: 'callout' + } + } +</script> +<script src="//cdn.jsdelivr.net/npm/docsify@4"></script> +<script src="https://unpkg.com/docsify-plugin-flexible-alerts"></script> +</body> +</html> diff --git a/package-lock.json b/package-lock.json index f7777b13592621c0a9ac3e7bb478770f678eedb1..65462d67e70c29034be4c61b83138b91adc252e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,10 @@ "@fortawesome/free-solid-svg-icons": "^6.2.0", "@popperjs/core": "^2.11.6", "@svgr/webpack": "^6.5.0", + "@types/d3-dsv": "^3.0.0", "bootstrap": "^5.2.1", "bootswatch": "^5.2.0", + "d3-dsv": "^3.0.1", "jquery": "3.6.0", "paper": "0.12.15", "react": "^18.2.0", @@ -2498,6 +2500,11 @@ "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==", "dev": true }, + "node_modules/@types/d3-dsv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.0.tgz", + "integrity": "sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A==" + }, "node_modules/@types/eslint": { "version": "8.4.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz", @@ -3903,6 +3910,49 @@ "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", "dev": true }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/date-format": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", @@ -7170,6 +7220,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -7193,8 +7248,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { "version": "1.55.0", @@ -10523,6 +10577,11 @@ "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==", "dev": true }, + "@types/d3-dsv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.0.tgz", + "integrity": "sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A==" + }, "@types/eslint": { "version": "8.4.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz", @@ -11659,6 +11718,31 @@ "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", "dev": true }, + "d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "requires": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" + }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "date-format": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", @@ -14109,6 +14193,11 @@ "queue-microtask": "^1.2.2" } }, + "rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -14118,8 +14207,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sass": { "version": "1.55.0", diff --git a/package.json b/package.json index 5dd0203d41e8d7352006a4af413164064367b142..0caa83558355a59b76662e17a5a45904d21ef8b8 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "@fortawesome/free-solid-svg-icons": "^6.2.0", "@popperjs/core": "^2.11.6", "@svgr/webpack": "^6.5.0", + "@types/d3-dsv": "^3.0.0", "bootstrap": "^5.2.1", "bootswatch": "^5.2.0", + "d3-dsv": "^3.0.1", "jquery": "3.6.0", "paper": "0.12.15", "react": "^18.2.0", diff --git a/src/data/coords/process/coordsMeasurement.ts b/src/data/coords/process/coordsMeasurement.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e323f5c5d5b4c50eac67f3985561177e4010362 --- /dev/null +++ b/src/data/coords/process/coordsMeasurement.ts @@ -0,0 +1,45 @@ +import {PathCoords} from "../pathCoords"; +import {Measurement} from "../../measurement/measurement"; +import {ToEllipseFitter} from "../transform/toEllipseFitter"; +import {ToConvexHull} from "../transform/toConvexHull"; +import {VectorCoords} from "../vectorCoords"; + +/** + * Transforme des coordonnées en Measures + */ +export class CoordsMeasurement { + + /** + * Effectue des mesures sur le coordonnées + */ + public measure(coords : PathCoords, scaleCoords : VectorCoords, scaleUnitCount: number, label: string) : Measurement { + let path = coords.toRemovedPath(); + path.closePath(); + + let linearScale = scaleCoords.distance() / scaleUnitCount; // pixels/cm + let areaScale = Math.pow(linearScale, 2); + let fittingEllipse = new ToEllipseFitter().transform(coords); + let convexHull = new ToConvexHull().transform(coords); + + let area = Math.abs(path.area) / areaScale; + let convexArea = Math.abs(convexHull.toRemovedPath().area) / areaScale; + let perimeter = path.length / linearScale; + let major = fittingEllipse.getMajorAxis() / linearScale; + let minor = fittingEllipse.getMinorAxis() / linearScale; + let circularity = 4 * Math.PI * area / Math.pow(perimeter, 2); + let ar = major / minor; + let round = (4 * area) / (Math.PI * major * major); + let solid = area / convexArea; + + return { + label: label, + area: area, + perimeter: perimeter, + circularity: circularity, + ar: ar, + round: round, solid: solid + } + } + + +} \ No newline at end of file diff --git a/src/data/coords/process/pointMerger.ts b/src/data/coords/process/pointMerger.ts new file mode 100644 index 0000000000000000000000000000000000000000..d748578743ca80a7f0113ef8101b51415643e25b --- /dev/null +++ b/src/data/coords/process/pointMerger.ts @@ -0,0 +1,67 @@ +/** + * Merge des point alignés horizontalement ou verticalement (4-connected) + */ +class PointMerger { + + private startPoint: paper.Point | null; + private endPoint: paper.Point | null; + + next(point : paper.Point | null) : paper.Point[] { + if(point == null) { // Null lorsque l'on a passé le dernier point => lus de point à merger, on retourne ce qu'il reste + if(this.endPoint != null) { + return [this.startPoint, this.endPoint]; + } else if(this.startPoint != null) { + return [this.startPoint]; + } else { + return []; + } + } else { + if (this.startPoint == null) { // On est sur le premier point + this.startPoint = point; + return []; + } else { + if(this.endPoint == null) { // On a déjà un point de départ + if(point.y == this.startPoint.y && point.x == this.startPoint.x) { + return [] + } else if(point.y == this.startPoint.y + || point.x == this.startPoint.x) { + this.endPoint = point; + return []; + } else { + let result = [this.startPoint]; + this.startPoint = point; + return result + } + } else { + if(point.y == this.endPoint.y && point.x == this.endPoint.x) { + return []; + + } else if(point.y == this.endPoint.y && Math.sign(this.endPoint.x - this.startPoint.x) == Math.sign(this.endPoint.x - this.startPoint.x)) { + // Dans l'alignement horizontal + this.endPoint = point; + return []; + + } else if(point.x == this.endPoint.x && Math.sign(this.endPoint.y - this.startPoint.y) == Math.sign(this.endPoint.y - this.startPoint.y)) { + // Dans l'alignement vertical + this.endPoint = point; + return []; + + } else { + if(point.y == this.endPoint.y || point.x == this.endPoint.x) { + let result = [this.startPoint]; + this.startPoint = this.endPoint; + this.endPoint = point; + return result; + + } else { + let result = [this.startPoint, this.endPoint]; + this.startPoint = point; + this.endPoint = null; + return result; + } + } + } + } + } + } +} diff --git a/src/data/dataExporter.ts b/src/data/dataExporter.ts index 732e6e74e980785e8757e2a8ec661af893b539ed..ac63b69ba50b4e752e485792b6e4226a72509018 100644 --- a/src/data/dataExporter.ts +++ b/src/data/dataExporter.ts @@ -1,160 +1,68 @@ -/** - * Utilitaire d'export des données - */ -import {LabData} from "../lab"; -import {ToEllipseFitter} from "./coords/transform/toEllipseFitter"; -import {Coords} from "./coords/coords"; -import {MathUtils} from "../utils/mathUtils"; -import {ToConvexHull} from "./coords/transform/toConvexHull"; -import {PathCoords} from "./coords/pathCoords"; - -const ROUNDING_DECIMALS = 3; - -/** - * Merge des point alignés horizontalement ou verticalement - */ -class PointMerger { - - private startPoint: paper.Point | null; - private endPoint: paper.Point | null; - - next(point : paper.Point | null) : paper.Point[] { - if(point == null) { // Null lorsque l'on a passé le dernier point => lus de point à merger, on retourne ce qu'il reste - if(this.endPoint != null) { - return [this.startPoint, this.endPoint]; - } else if(this.startPoint != null) { - return [this.startPoint]; - } else { - return []; - } - } else { - if (this.startPoint == null) { // On est sur le premier point - this.startPoint = point; - return []; - } else { - if(this.endPoint == null) { // On a déjà un point de départ - if(point.y == this.startPoint.y && point.x == this.startPoint.x) { - return [] - } else if(point.y == this.startPoint.y - || point.x == this.startPoint.x) { - this.endPoint = point; - return []; - } else { - let result = [this.startPoint]; - this.startPoint = point; - return result - } - } else { - if(point.y == this.endPoint.y && point.x == this.endPoint.x) { - return []; - - } else if(point.y == this.endPoint.y && Math.sign(this.endPoint.x - this.startPoint.x) == Math.sign(this.endPoint.x - this.startPoint.x)) { - // Dans l'alignement horizontal - this.endPoint = point; - return []; - - } else if(point.x == this.endPoint.x && Math.sign(this.endPoint.y - this.startPoint.y) == Math.sign(this.endPoint.y - this.startPoint.y)) { - // Dans l'alignement vertical - this.endPoint = point; - return []; - - } else { - if(point.y == this.endPoint.y || point.x == this.endPoint.x) { - let result = [this.startPoint]; - this.startPoint = this.endPoint; - this.endPoint = point; - return result; - - } else { - let result = [this.startPoint, this.endPoint]; - this.startPoint = point; - this.endPoint = null; - return result; - } - } - } - } - } - } -} - - -export class DataExporter { - - /** - * Transforme un Path en CSV - */ - public exportPathPointsAsXYCsv(coords : Coords, close : boolean) : string { - const path = coords.toRemovedPath(); - let data = ""; - let pointMerger: PointMerger = new PointMerger(); - for (let i = 0; i < path.length; i++) { - let point = path.getPointAt(i).round(); - pointMerger.next(point).forEach( - (p) => data += this.toCsv(p) - ); - } - pointMerger.next(null).forEach( - (p) => data += this.toCsv(p) - ); - return data; - } - - /** - * Transforme un Path en CSV par segments - */ - public exportPathSegmentsAsXYCsv(coords : Coords, close : boolean) : string { - const path = coords.toRemovedPath(); - let data = ""; - let pointMerger: PointMerger = new PointMerger(); - for (let i = 0; i < path.segments.length; i++) { - let segment = path.segments[i]; - data += this.toCsv(segment.point.round()); - } - return data; - } - - /** - * Transforme un Path en CSV - */ - public exportPathDescriptorsAsCsv(labData : LabData, coords : PathCoords) : string { - let path = coords.toRemovedPath(); - path.closePath(); - - let linearScale = labData.rulerCoords.distance() / labData.rulerTickCount; // pixels/cm - let areaScale = Math.pow(linearScale, 2); - let fittingEllipse = new ToEllipseFitter().transform(coords); - let convexHull = new ToConvexHull().transform(coords); - - let line = 1; - let label = labData.filename; - let area = Math.abs(path.area) / areaScale; - let convexArea = Math.abs(convexHull.toRemovedPath().area) / areaScale; - let perimeter = path.length / linearScale; - let major = fittingEllipse.getMajorAxis() / linearScale; - let minor = fittingEllipse.getMinorAxis() / linearScale; - let circularity = 4 * Math.PI * area / Math.pow(perimeter, 2); - let ar = major / minor; - let round = (4 * area) / (Math.PI * major * major); - let solid = area / convexArea; - - let headers : string[] = [ " ", "Label", "Area", "Perim.", "Circ.","AR","Round","Solidity"]; - let data = [ line, - label, - MathUtils.round(area, ROUNDING_DECIMALS), - MathUtils.round(perimeter, ROUNDING_DECIMALS), - MathUtils.round(circularity, ROUNDING_DECIMALS), - MathUtils.round(ar, ROUNDING_DECIMALS), - MathUtils.round(round, ROUNDING_DECIMALS), - MathUtils.round(solid, ROUNDING_DECIMALS), - ] - - return headers.join(",") + "\n" + data.join(","); - } - - - private toCsv(point : paper.Point) : string { - return point.x + "\t" + point.y + "\n"; - } - +/** + * Utilitaire d'export des données + */ +import {LabData} from "../lab"; +import {Coords} from "./coords/coords"; +import {MeasurementExport} from "./measurement/process/measurementExport"; +import {CoordsMeasurement} from "./coords/process/coordsMeasurement"; + +export class DataExporter { + + /** + * Pour effectuer les mesures + */ + private coordsMeasure = new CoordsMeasurement(); + + /** + * Pour l'export des données de mesures + */ + private measureExport: MeasurementExport = new MeasurementExport(); + + /** + * Transforme un Path en CSV + */ + public exportPathPointsAsXYCsv(coords : Coords, close : boolean) : string { + const path = coords.toRemovedPath(); + let data = ""; + let pointMerger = new PointMerger(); + for (let i = 0; i < path.length; i++) { + let point = path.getPointAt(i).round(); + pointMerger.next(point).forEach( + (p) => data += this.toCsv(p) + ); + } + pointMerger.next(null).forEach( + (p) => data += this.toCsv(p) + ); + return data; + } + + /** + * Transforme un Path en CSV par segments + */ + public exportPathSegmentsAsXYCsv(coords : Coords, close : boolean) : string { + const path = coords.toRemovedPath(); + let data = ""; + let pointMerger: PointMerger = new PointMerger(); + for (let i = 0; i < path.segments.length; i++) { + let segment = path.segments[i]; + data += this.toCsv(segment.point.round()); + } + return data; + } + + /** + * Transforme un Path en CSV + */ + public exportPathDescriptorsAsCsv(labData : LabData) : string { + let measurement = this.coordsMeasure.measure(labData.blobMaskCoords, labData.rulerCoords, labData.rulerTickCount, labData.filename); + return this.measureExport.exportMeasurements(labData.workInfo,[measurement]).csv; + } + + + private toCsv(point : paper.Point) : string { + return point.x + "\t" + point.y + "\n"; + } + + } \ No newline at end of file diff --git a/src/data/dataImporter.ts b/src/data/dataImporter.ts index a1e475f01c124462efaf0ce0b132167736645e97..b1521476376a0b262e8c6244727cfd00af65ffdc 100644 --- a/src/data/dataImporter.ts +++ b/src/data/dataImporter.ts @@ -1,25 +1,47 @@ -import {Coords} from "./coords/coords"; -import {PathCoords} from "./coords/pathCoords"; -import {StringUtils} from "../utils/stringUtils"; -import * as paper from "paper"; - -/** - * Import de données, sert plutôt ôu rle debug - */ -export class DataImporter { - - /** - * Charge un PathCoords depuis des coordonnées text - */ - public import(text: string): PathCoords { - let pathCoords = new PathCoords(); - let lines = StringUtils.splitLines(text); - lines.filter(line => line.trim().length > 0).forEach( - (line: string) => { - let [x, y] = line.split("\t"); - pathCoords.points.push(new paper.Point(Number(x), Number(y))); - } - ); - return pathCoords; - } -} +import {PathCoords} from "./coords/pathCoords"; +import {StringUtils} from "../utils/stringUtils"; +import * as paper from "paper"; +import {ExperienceEnum, GroupeEnum, WorkInfo} from "./work/workInfo"; +import {Measurement} from "./measurement/measurement"; +import {csvParse} from "d3-dsv"; + + + +/** + * Le contenu d'un fichier CSV de mesure (pour une photo) + */ +export interface CsvResultFileData { + filename: string, + workInfo: WorkInfo, + measures: Measurement[] +} + + +/** + * Import de données, sert plutôt pour le debug + */ +export class DataImporter { + + /** + * Fichier de résultats + */ + public readCsvResultFileData(filename: string, csv: string) : CsvResultFileData { + console.log(csvParse(csv)); + return null; + } + + /** + * Charge un PathCoords depuis des coordonnées text + */ + public readPathCoords(text: string): PathCoords { + let pathCoords = new PathCoords(); + let lines = StringUtils.splitLines(text); + lines.filter(line => line.trim().length > 0).forEach( + (line: string) => { + let [x, y] = line.split("\t"); + pathCoords.points.push(new paper.Point(Number(x), Number(y))); + } + ); + return pathCoords; + } +} diff --git a/src/data/measurement/measurement.ts b/src/data/measurement/measurement.ts new file mode 100644 index 0000000000000000000000000000000000000000..7980351622126d2ae1d5d2c4975bc361b341155e --- /dev/null +++ b/src/data/measurement/measurement.ts @@ -0,0 +1,12 @@ +/** + * Contient les mesures métriques + */ +export interface Measurement { + label: string, // le nom du fichier + area: number, + perimeter: number, + circularity: number, + ar: number, + round: number, + solid: number +} \ No newline at end of file diff --git a/src/data/measurement/measurementWorks.ts b/src/data/measurement/measurementWorks.ts new file mode 100644 index 0000000000000000000000000000000000000000..13fcd7ea812e1724bf04f0407f083bed098ea01d --- /dev/null +++ b/src/data/measurement/measurementWorks.ts @@ -0,0 +1,14 @@ +import {WorkInfo} from "../work/workInfo"; +import {Measurement} from "./measurement"; + +/** + * Regroupe des mesures par sessions de travail + */ +export interface MeasurementWorks { + + [work: string]: { + work: WorkInfo, + files: { [filename: string] : Measurement[] } + } + +} \ No newline at end of file diff --git a/src/data/measurement/process/measurementExport.ts b/src/data/measurement/process/measurementExport.ts new file mode 100644 index 0000000000000000000000000000000000000000..78a3d6082fc9b97f7c77e0351fbfdee6bd0dab2e --- /dev/null +++ b/src/data/measurement/process/measurementExport.ts @@ -0,0 +1,44 @@ +import {Measurement} from "../measurement"; +import {MathUtils} from "../../../utils/mathUtils"; +import {WorkInfo} from "../../work/workInfo"; +import {csvFormatRows} from "d3-dsv"; +import {WorkInfoFormatter} from "../../work/process/workInfoFormatter"; + +const ROUNDING_DECIMALS = 3; + +/** + * Export de mesures + */ +export class MeasurementExport { + + private workInfoFormatter = new WorkInfoFormatter(); + + + /** + * Export les mesures au format CSV + */ + public exportMeasurements(work: WorkInfo, measurements: Measurement[]) : { filename: string, csv: string } { + return { + filename: "Results_" + this.workInfoFormatter.format(work) + ".csv", + csv: csvFormatRows([[" ", "Label", "Area", "Perim.", "Circ.","AR","Round","Solidity"]] + .concat(measurements.map((measurement, i) => + [ (i + 1).toString(), + measurement.label, + MathUtils.round(measurement.area, ROUNDING_DECIMALS).toString(), + MathUtils.round(measurement.perimeter, ROUNDING_DECIMALS).toString(), + MathUtils.round(measurement.circularity, ROUNDING_DECIMALS).toString(), + MathUtils.round(measurement.ar, ROUNDING_DECIMALS).toString(), + MathUtils.round(measurement.round, ROUNDING_DECIMALS).toString(), + MathUtils.round(measurement.solid, ROUNDING_DECIMALS).toString(), + ] + )))}; + } + /** + * Export les mesures au format CSV + */ + public exportDryMeasures(work: WorkInfo) : { filename: string, csv: string } { + return this.exportMeasurements(work, []); + } + + +} \ No newline at end of file diff --git a/src/data/measurement/process/measurementImport.ts b/src/data/measurement/process/measurementImport.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d318d585236d22ff660762d73c018632d065ba1 --- /dev/null +++ b/src/data/measurement/process/measurementImport.ts @@ -0,0 +1,56 @@ +/** + * Import de mesures + */ +import {Measurement} from "../measurement"; +import {WorkInfo} from "../../work/workInfo"; +import {WorkInfoParser} from "../../work/process/workInfoParser"; +import {csvParseRows} from "d3-dsv"; + +const MEASURE_FILENAME_REGEX = /^Results_(?<work>[a-zA-Z0-9]+).csv$/i; + +/** + * EN charge de l'import de mesures (lecture de fichiers CSV) + */ +export class MeasurementImport { + + /** + * Poru parser la session de travail + */ + private workInfoParser = new WorkInfoParser(); + + /** + * Lit des mesures depuis des données csv + * @param String + */ + public readMeasures(filename: string, csv: string) : { work: WorkInfo, measurements: Measurement[] } { + let match = filename.match(MEASURE_FILENAME_REGEX); + let work = null; + if(match != null) { + work = this.workInfoParser.parse(match.groups.work); + } + if(work == null) { + throw new Error("Le nom du fichier " + filename + " ne respecte pas la nomenclature (par ex.: Results_ExpJ2ConB3.csv)"); + } + + let measurements : Measurement[] = csvParseRows(csv, (d, i) : Measurement => { + if(i == 0) { + if(d.length != 8 || d[0] != " " || d[1] != "Label" || d[2] != "Area" || d[3] != "Perim." || d[4] != "Circ." || d[5] != "AR" || d[6] != "Round" || d[7] != "Solidity" ) { + throw new Error("Fichier " + filename + " : la première ligne du fichier ne contient pas les en-têtes attendus (\" ,Label,Area,Perim.,Circ.,AR,Round,Solidity\")") + } + } else { + return { + label: d[1], // le nom du fichier + area: +d[2], + perimeter: +d[3], + circularity: +d[4], + ar: +d[5], + round: +d[6], + solid: +d[7] + }; + } + }); + return { work: work, measurements: measurements }; + } + + +} \ No newline at end of file diff --git a/src/data/measurement/process/measurementMerge.ts b/src/data/measurement/process/measurementMerge.ts new file mode 100644 index 0000000000000000000000000000000000000000..77a040d4df88b40766bf7f3b8d4d4f82c97f85b8 --- /dev/null +++ b/src/data/measurement/process/measurementMerge.ts @@ -0,0 +1,45 @@ +/** + * Merge des mesures + */ +import {MeasurementWorks} from "../measurementWorks"; +import {WorkInfo} from "../../work/workInfo"; +import {Measurement} from "../measurement"; +import {ObjectUtils} from "../../../utils/objectUtils"; +import {MeasurementImport} from "./measurementImport"; +import {WorkInfoFormatter} from "../../work/process/workInfoFormatter"; + +/** + * En charge du merge des mesures + */ +export class MeasurementMerge { + + /** + * Pour importer les données CSV + */ + private measurementImport = new MeasurementImport(); + + /** + * Pour encoder le WorkInfo + */ + private workInfoFormatter = new WorkInfoFormatter(); + + /** + * Ajoute un fichier de mesures dans + */ + public mergeInto(measurementWorks: MeasurementWorks, filename: string, work: WorkInfo, measurements: Measurement[]) : MeasurementWorks { + // La session de travail (ensemble de blobs) + let groupWork = ObjectUtils.deepClone(work); + groupWork.blob = null; + + measurementWorks = ObjectUtils.deepClone(measurementWorks); + + let workKey = this.workInfoFormatter.format(groupWork); + if (measurementWorks[workKey] == null) { + measurementWorks[workKey] = { work: groupWork, files: {}}; + } + measurementWorks[workKey].files[filename] = measurements; + return measurementWorks; + } + + +} \ No newline at end of file diff --git a/src/data/work/process/workInfoFormatter.ts b/src/data/work/process/workInfoFormatter.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c41520437c4f4986b30a226263a694677b22e13 --- /dev/null +++ b/src/data/work/process/workInfoFormatter.ts @@ -0,0 +1,22 @@ +/** + * Import et export des infos de travail en cours + */ +import {WorkInfo} from "../workInfo"; + +/** + * Formattage des infos du travail en cours + */ +export class WorkInfoFormatter { + + /** + * Transforme le work info en code + */ + public format(workInfo : WorkInfo) : string { + return workInfo.groupe + + "J" + workInfo.jour + + workInfo.experience + + (workInfo.blob != null ? "B" + workInfo.blob : ""); + } + + +} \ No newline at end of file diff --git a/src/data/work/process/workInfoParser.ts b/src/data/work/process/workInfoParser.ts new file mode 100644 index 0000000000000000000000000000000000000000..76e9b5f1799399a9db7a9e8890a735bee01a6249 --- /dev/null +++ b/src/data/work/process/workInfoParser.ts @@ -0,0 +1,28 @@ +import {ExperienceEnum, GroupeEnum, WorkInfo} from "../workInfo"; + + +const WORK_REGEXP = /^(?<groupe>Con|Exp)J(?<jour>[0-9]+)(?<experience>Cr|Ex)(B(?<blob>[0-9]+))?$/i; + +/** + * Parsing de code de travail en cours + */ +export class WorkInfoParser { + + /** + * Valide le bom de fichier + */ + public parse(name : string) : WorkInfo | null { + let match = name.match(WORK_REGEXP); + if(match == null) { + return null; + } else { + return { + groupe: match.groups.groupe.toLowerCase() == 'Con'.toLowerCase() ? GroupeEnum.Controle : GroupeEnum.Experimental, + jour: parseInt(match.groups.jour), + experience: match.groups.experience.toLowerCase() == 'Cr'.toLowerCase() ? ExperienceEnum.Croissance : ExperienceEnum.Exploration, + blob: match.groups.blob != null ? parseInt(match.groups.blob) : null, + } + } + } + +} \ No newline at end of file diff --git a/src/data/work/workInfo.ts b/src/data/work/workInfo.ts new file mode 100644 index 0000000000000000000000000000000000000000..88d6aebdf0c67fc44da6456372769c4e8cd413fa --- /dev/null +++ b/src/data/work/workInfo.ts @@ -0,0 +1,25 @@ +/** + * Le boîte de blob + */ +export enum GroupeEnum { + Experimental = 'Exp', + Controle = 'Con' +} + +/** + * La phase du blob + */ +export enum ExperienceEnum { + Croissance = 'Cr', + Exploration = 'Ex' +} + +/** + * Informations sur la photo + */ +export interface WorkInfo { + groupe : GroupeEnum, + jour : number, + experience : ExperienceEnum, + blob: number; +} \ No newline at end of file diff --git a/src/lab.tsx b/src/lab.tsx index 654753b806b4aa5084045597608b1e2c07deea56..97d17585a262cf41a0e32df9c32453a94e80c7f4 100644 --- a/src/lab.tsx +++ b/src/lab.tsx @@ -1,348 +1,372 @@ -import * as React from 'react'; -import * as paper from "paper"; -import {StepManager} from "./ui/steps/stepManager"; -import {LoadPictureStep} from "./ui/steps/loadPictureStep"; -import {RulerStep} from "./ui/steps/rulerStep"; -import {Badge, Button, ButtonGroup, Col, Container, Form, Navbar, Row} from "react-bootstrap"; -import {Ruler} from "./instruments/ruler"; -import {PlacePetriDishStep} from "./ui/steps/placePetriDishStep"; -import {PetriDish} from "./instruments/petriDish"; -import {DrawBlobMaskStep} from "./ui/steps/drawBlobMaskStep"; -import {BlobMask} from "./instruments/blobMask"; -import {VectorCoords} from "./data/coords/vectorCoords"; -import {PathCoords} from "./data/coords/pathCoords"; -import {DownloadStep} from "./ui/steps/downloadStep"; -import {PaperUtils} from "./utils/paperUtils"; -import {EllipseCoords} from "./data/coords/ellipseCoords"; -import {Welcome} from "./ui/welcome"; - -/** - * Debug mode (ou pas) - */ -export const DEBUG_MODE = false; - -/** - * La taille par défaut de la règle - */ -export const DEFAULT_RULER_TICK_COUNT = 10; - -/** - * La taille minimum de la règle - */ -export const MIN_RULER_TICK_COUNT = 8; - -/** - * La taille maximum de la règle - */ -export const MAX_RULER_TICK_COUNT = 15; - - -export interface LabData { - pictureSize : paper.Size, - - filename : string, - - rulerTickCount : number, - - rulerCoords : VectorCoords, - - petriDishCoords : EllipseCoords, - - blobMaskCoords : PathCoords -} - - -/** - * Le lab - */ -export class Lab extends React.Component<{}> { - - /** - * Récolte des données - */ - public data : LabData | null; - - /** - * Le canvas sur lequel est dessiné la photo - */ - private canvas : HTMLCanvasElement | null = null; - - /** - * Le canvas sur lequel est dessiné la photo - */ - private welcome = React.createRef<Welcome>(); - - /** - * La photo du blob - */ - public raster : null | paper.Raster = null; - - /** - * La règle - */ - public ruler: Ruler; - - /** - * La boîte de petri - */ - public petriDish: PetriDish; - - /** - * Le contour du blob - */ - public blobMask: BlobMask; - - public constructor(props : {}) { - super(props); - } - - /** - * Post-init lorsque le lab est affiché - */ - public componentDidMount() { - if(this.canvas == null) { - throw new Error("Canvas should have been initiated"); - } - - paper.setup(this.canvas); - new paper.Tool(); - paper.tool.activate(); - - // Ajout du zoom avec la molette - this.canvas.addEventListener('wheel', (event) => { - let target = paper.view.viewToProject(new paper.Point(event.offsetX, event.offsetY)); - if (event.deltaY > 0) { - this.zoomOut(target); - } else { - this.zoomIn(target); - } - }); - - // Ajout des déplacements - paper.tool.onKeyDown = (event : paper.KeyEvent) => { - if(event.key == "control") { - PaperUtils.changeCursor("move"); - } - } - paper.tool.onKeyUp = (event : paper.KeyEvent) => { - if(event.key == "control") { - PaperUtils.changeCursor(null); - } - } - paper.tool.onMouseDrag = function(event) { - if(event.modifiers.control) { - let delta = event.point.subtract(event.downPoint); - paper.view.center = paper.view.center.subtract(delta); - event.preventDefault(); - } - } - - // Prise en compte du redimensionnement navigateur (ou agrandissement/retrécissement de la vue) - paper.view.onResize = (event) => { - if(this.raster != null) { - paper.view.center = this.raster.position; - } - } - } - - /** - * Lance une nouvelle session de travail - */ - public new(image : HTMLImageElement, filename : string) : boolean { - console.info("Nouvelle session de travail") - if(this.raster != null) { - if(window.confirm("Écraser le travail en cours ?")) { - this.reset(); - } else { - return false; - } - } - - // Masque le composant d'accueil - this.welcome.current.setState({visible: false }); - - // Init du Raster de l'image - this.raster = new paper.Raster(); - this.raster.image = image; - this.raster.bounds.point = new paper.Point(0, 0); - this.raster.smoothing = 'off'; - - // Init des données - let width = image.naturalWidth; - let height = image.naturalHeight; - this.data = { - pictureSize: new paper.Size(width, height), - - filename: filename, - - rulerTickCount : DEFAULT_RULER_TICK_COUNT, - - rulerCoords: new VectorCoords(new paper.Point(width * 0.25, height / 2), new paper.Point(width * 0.75, height / 2)), - - petriDishCoords: new EllipseCoords(new paper.Point(width / 2, height / 2), width * 0.75 / 2, width * 0.75 / 2, 0), - - blobMaskCoords: new PathCoords(), - }; - - // Le plus en dessous en premier - this.blobMask = new BlobMask(this, this.data.blobMaskCoords); - this.petriDish = new PetriDish(this, this.data.petriDishCoords); - this.ruler = new Ruler(this, this.data.rulerCoords); - - // Zoom global - this.zoomFit(); - - // Petit refresh - paper.view.update(); - - return true; - } - - /** - * Remet à zéro l'espace de travail - */ - private reset() : void { - if(this.raster != null) { - this.raster.remove(); - } - this.raster = null; - - // Vidage des instruments - this.ruler.clear(); - this.petriDish.clear(); - this.blobMask.clear(); - - } - - /** - * Zoom au plus près de la photo - */ - public zoomFit() : void { - if(this.raster != null && this.canvas != null && this.data.pictureSize != null) { - this.zoomOn(new paper.Rectangle(0, 0, this.data.pictureSize.width, this.data.pictureSize.height)) - } - } - - /** - * Zoom sur une zone - */ - public zoomOn(rectangle : paper.Rectangle, marginPercent : number = 0) { - paper.view.center = rectangle.center; - let xZoomFactor = Math.min(1, this.canvas.width / rectangle.width); - let yZoomFactor = Math.min(1, this.canvas.height / rectangle.height); - paper.view.zoom = Math.min(xZoomFactor, yZoomFactor) * (1 - marginPercent) / paper.view.pixelRatio; - paper.view.update(); - this.onZoomChanged(); - } - - /** - * Plus de zoom - */ - public zoomIn(target? : paper.Point) : void { - this.zoom(1.10, target); - } - - // Zooms par delta - - /** - * Moins de zoom - */ - public zoomOut(target? : paper.Point) : void { - this.zoom(0.90, target); - } - - /** - * Pas de zoom 1:1 - */ - public zoomNone() : void { - paper.view.zoom = 1; - this.onZoomChanged(); - } - - /** - * Centralise le zoom - */ - private zoom(zoomFactor : number, target? : paper.Point) { - let oldZoom = paper.view.zoom; - let newZoom = oldZoom * zoomFactor; - - if(target != undefined) { - // let viewPosition = paper.view.viewToProject(target); - let viewPosition = target; - let mpos = viewPosition; - let ctr = paper.view.center; - - let pc = mpos.subtract(ctr); - let offset = mpos.subtract(pc.multiply(0.95 / zoomFactor)).subtract(ctr); - - paper.view.center = paper.view.center.add(offset); - } - - // On borne les zooms pour ne pas que ça parte partout - // Zoom minimum = zoom fit - let minZoom = 0; - if(this.data != null) { - let xZoomFactor = Math.min(1, this.canvas.width / this.data.pictureSize.width) - let yZoomFactor = Math.min(1, this.canvas.height / this.data.pictureSize.height); - minZoom = Math.min(xZoomFactor, yZoomFactor) / paper.view.pixelRatio; - } - let maxZoom = 10; - paper.view.zoom = Math.min(maxZoom, Math.max(newZoom, minZoom)); - this.onZoomChanged(); - } - - /** - * Appelé lorsque le zoom a changé - */ - private onZoomChanged() { - this.ruler?.refresh(); - this.petriDish?.refresh(); - this.blobMask?.refresh(); - } - - render(): React.ReactNode { - return <Container fluid={true} className={"vh-100 d-flex flex-column"}> - <Navbar bg="light" expand="lg" className={"p-0"}> - <Container fluid={true}> - <Row className={"col-md-12"}> - <div className="d-flex d-flex justify-content-between"> - <Navbar.Brand className={"p-0"}><i className={"fa-solid fa-flask me-2"}></i>Blob Analysis Lab <sup><Badge pill bg="secondary" text="primary">demo</Badge></sup></Navbar.Brand> - <Form className={"inline-form"}> - <Form.Group controlId="zoomGroup"> - <Form.Label>Zoom :</Form.Label> - <ButtonGroup aria-label="Zoom" className={"ms-2"}> - <Button onClick={() => this.zoomIn()} variant={"primary"} size={"sm"}><i className="fa-solid fa-magnifying-glass-plus"></i></Button> - <Button onClick={() => this.zoomOut()} variant={"primary"} size={"sm"}><i className="fa-solid fa-magnifying-glass-minus"></i></Button> - <Button onClick={() => this.zoomFit()} variant={"primary"} size={"sm"}><i className="fa-regular fa-image"></i></Button> - <Button onClick={() => this.zoomNone()} variant={"primary"} size={"sm"}>1:1</Button> - </ButtonGroup> - <Form.Label className={"ms-3"}>[CTRL] + <i className="fa-solid fa-computer-mouse"></i> = <i className="fa-solid fa-arrows-up-down-left-right"></i></Form.Label> - </Form.Group> - </Form> - <div> - <Button href={"docs/index.html"} target="_blank" variant={"primary"} size={"sm"}>Tutoriel<i className="ms-2 fa-solid fa-book"></i></Button> - </div> - </div> - </Row> - </Container> - </Navbar> - <Row className="flex-grow-1"> - <div className="col position-relative"> - <Welcome ref={this.welcome}/> - <canvas ref={(canvas : HTMLCanvasElement)=> this.canvas = canvas} data-paper-resize="false" className="h-100 w-100 d-block" onContextMenu={() => false}></canvas> - </div> - <Col md={4} className={"border-2"} > - <div className="mb-3"> - <StepManager> - <LoadPictureStep lab={this} code="loadPicture" title="Charger une photo"/> - <RulerStep lab={this} code="placeRuler" title="Positionner la règle"/> - <PlacePetriDishStep lab={this} code="placePetriDish" title="Positionner la boîte de petri"/> - <DrawBlobMaskStep lab={this} code="drawBlobMask" title="Détourer le blob"/> - <DownloadStep lab={this} code="download" title="Télécharger les résultats"/> - </StepManager> - </div> - </Col> - </Row> - </Container> - } +import * as React from 'react'; +import * as paper from "paper"; +import {StepManager} from "./ui/steps/stepManager"; +import {LoadPictureStep} from "./ui/steps/loadPictureStep"; +import {RulerStep} from "./ui/steps/rulerStep"; +import {Badge, Button, ButtonGroup, Col, Container, Form, Navbar, Row} from "react-bootstrap"; +import {Ruler} from "./instruments/ruler"; +import {PlacePetriDishStep} from "./ui/steps/placePetriDishStep"; +import {PetriDish} from "./instruments/petriDish"; +import {DrawBlobMaskStep} from "./ui/steps/drawBlobMaskStep"; +import {BlobMask} from "./instruments/blobMask"; +import {VectorCoords} from "./data/coords/vectorCoords"; +import {PathCoords} from "./data/coords/pathCoords"; +import {DownloadStep} from "./ui/steps/downloadStep"; +import {PaperUtils} from "./utils/paperUtils"; +import {EllipseCoords} from "./data/coords/ellipseCoords"; +import {Welcome} from "./ui/welcome"; +import {DataImporter} from "./data/dataImporter"; +import {WorkInfo} from "./data/work/workInfo"; +import {IoUtils} from "./utils/ioUtils"; +import {WorkInfoParser} from "./data/work/process/workInfoParser"; + +/** + * Debug mode (ou pas) + */ +export const DEBUG_MODE = false; + +/** + * La taille par défaut de la règle + */ +export const DEFAULT_RULER_TICK_COUNT = 10; + +/** + * La taille minimum de la règle + */ +export const MIN_RULER_TICK_COUNT = 8; + +/** + * La taille maximum de la règle + */ +export const MAX_RULER_TICK_COUNT = 15; + + +export interface LabData { + + pictureSize : paper.Size, + + filename : string, + + workInfo : WorkInfo, + + rulerTickCount : number, + + rulerCoords : VectorCoords, + + petriDishCoords : EllipseCoords, + + blobMaskCoords : PathCoords +} + + +/** + * Le lab + */ +export class Lab extends React.Component<{}> { + + /** + * Récolte des données + */ + public data : LabData | null; + + /** + * Le canvas sur lequel est dessiné la photo + */ + private canvas : HTMLCanvasElement | null = null; + + /** + * Le canvas sur lequel est dessiné la photo + */ + private welcome = React.createRef<Welcome>(); + + /** + * La photo du blob + */ + public raster : null | paper.Raster = null; + + /** + * La règle + */ + public ruler: Ruler; + + /** + * La boîte de petri + */ + public petriDish: PetriDish; + + /** + * Le contour du blob + */ + public blobMask: BlobMask; + + /** + * Outil d'import de données + */ + public dataImporter = new DataImporter(); + + /** + * Parsing du work in progress + */ + public workInfoParser = new WorkInfoParser(); + + public constructor(props : {}) { + super(props); + } + + /** + * Post-init lorsque le lab est affiché + */ + public componentDidMount() { + if(this.canvas == null) { + throw new Error("Canvas should have been initiated"); + } + + paper.setup(this.canvas); + new paper.Tool(); + paper.tool.activate(); + + // Ajout du zoom avec la molette + this.canvas.addEventListener('wheel', (event) => { + let target = paper.view.viewToProject(new paper.Point(event.offsetX, event.offsetY)); + if (event.deltaY > 0) { + this.zoomOut(target); + } else { + this.zoomIn(target); + } + }); + + // Ajout des déplacements + paper.tool.onKeyDown = (event : paper.KeyEvent) => { + if(event.key == "control") { + PaperUtils.changeCursor("move"); + } + } + paper.tool.onKeyUp = (event : paper.KeyEvent) => { + if(event.key == "control") { + PaperUtils.changeCursor(null); + } + } + paper.tool.onMouseDrag = function(event) { + if(event.modifiers.control) { + let delta = event.point.subtract(event.downPoint); + paper.view.center = paper.view.center.subtract(delta); + event.preventDefault(); + } + } + + // Prise en compte du redimensionnement navigateur (ou agrandissement/retrécissement de la vue) + paper.view.onResize = (event) => { + if(this.raster != null) { + paper.view.center = this.raster.position; + } + } + } + + /** + * Lance une nouvelle session de travail + */ + public new(image : HTMLImageElement, filename : string) : boolean { + console.info("Nouvelle session de travail") + let workInfo = this.workInfoParser.parse(IoUtils.basename(filename)); + if(workInfo == null) { + window.alert("Le nom du fichier ne respecte pas nomenclature") + return false; + } + if(this.raster != null) { + if(window.confirm("Écraser le travail en cours ?")) { + this.reset(); + } else { + return false; + } + } + + // Masque le composant d'accueil + this.welcome.current.setState({visible: false }); + + // Init du Raster de l'image + this.raster = new paper.Raster(); + this.raster.image = image; + this.raster.bounds.point = new paper.Point(0, 0); + this.raster.smoothing = 'off'; + + // Init des données + let width = image.naturalWidth; + let height = image.naturalHeight; + this.data = { + pictureSize: new paper.Size(width, height), + + filename: filename, + + workInfo: workInfo, + + rulerTickCount : DEFAULT_RULER_TICK_COUNT, + + rulerCoords: new VectorCoords(new paper.Point(width * 0.25, height / 2), new paper.Point(width * 0.75, height / 2)), + + petriDishCoords: new EllipseCoords(new paper.Point(width / 2, height / 2), width * 0.75 / 2, width * 0.75 / 2, 0), + + blobMaskCoords: new PathCoords(), + }; + + // Le plus en dessous en premier + this.blobMask = new BlobMask(this, this.data.blobMaskCoords); + this.petriDish = new PetriDish(this, this.data.petriDishCoords); + this.ruler = new Ruler(this, this.data.rulerCoords); + + // Zoom global + this.zoomFit(); + + // Petit refresh + paper.view.update(); + + return true; + } + + /** + * Remet à zéro l'espace de travail + */ + private reset() : void { + if(this.raster != null) { + this.raster.remove(); + } + this.raster = null; + + // Vidage des instruments + this.ruler.clear(); + this.petriDish.clear(); + this.blobMask.clear(); + + } + + /** + * Zoom au plus près de la photo + */ + public zoomFit() : void { + if(this.raster != null && this.canvas != null && this.data.pictureSize != null) { + this.zoomOn(new paper.Rectangle(0, 0, this.data.pictureSize.width, this.data.pictureSize.height)) + } + } + + /** + * Zoom sur une zone + */ + public zoomOn(rectangle : paper.Rectangle, marginPercent : number = 0) { + paper.view.center = rectangle.center; + let xZoomFactor = Math.min(1, this.canvas.width / rectangle.width); + let yZoomFactor = Math.min(1, this.canvas.height / rectangle.height); + paper.view.zoom = Math.min(xZoomFactor, yZoomFactor) * (1 - marginPercent) / paper.view.pixelRatio; + paper.view.update(); + this.onZoomChanged(); + } + + /** + * Plus de zoom + */ + public zoomIn(target? : paper.Point) : void { + this.zoom(1.10, target); + } + + // Zooms par delta + + /** + * Moins de zoom + */ + public zoomOut(target? : paper.Point) : void { + this.zoom(0.90, target); + } + + /** + * Pas de zoom 1:1 + */ + public zoomNone() : void { + paper.view.zoom = 1; + this.onZoomChanged(); + } + + /** + * Centralise le zoom + */ + private zoom(zoomFactor : number, target? : paper.Point) { + let oldZoom = paper.view.zoom; + let newZoom = oldZoom * zoomFactor; + + if(target != undefined) { + // let viewPosition = paper.view.viewToProject(target); + let viewPosition = target; + let mpos = viewPosition; + let ctr = paper.view.center; + + let pc = mpos.subtract(ctr); + let offset = mpos.subtract(pc.multiply(0.95 / zoomFactor)).subtract(ctr); + + paper.view.center = paper.view.center.add(offset); + } + + // On borne les zooms pour ne pas que ça parte partout + // Zoom minimum = zoom fit + let minZoom = 0; + if(this.data != null) { + let xZoomFactor = Math.min(1, this.canvas.width / this.data.pictureSize.width) + let yZoomFactor = Math.min(1, this.canvas.height / this.data.pictureSize.height); + minZoom = Math.min(xZoomFactor, yZoomFactor) / paper.view.pixelRatio; + } + let maxZoom = 10; + paper.view.zoom = Math.min(maxZoom, Math.max(newZoom, minZoom)); + this.onZoomChanged(); + } + + /** + * Appelé lorsque le zoom a changé + */ + private onZoomChanged() { + this.ruler?.refresh(); + this.petriDish?.refresh(); + this.blobMask?.refresh(); + } + + render(): React.ReactNode { + return <Container fluid={true} className={"vh-100 d-flex flex-column"}> + <Navbar bg="light" expand="lg" className={"p-0"}> + <Container fluid={true}> + <Row className={"col-md-12"}> + <div className="d-flex d-flex justify-content-between"> + <Navbar.Brand className={"p-0"}><i className={"fa-solid fa-flask me-2"}></i>Blob Analysis Lab <sup><Badge pill bg="secondary" text="primary">demo</Badge></sup></Navbar.Brand> + <Form className={"inline-form"}> + <Form.Group controlId="zoomGroup"> + <Form.Label>Zoom :</Form.Label> + <ButtonGroup aria-label="Zoom" className={"ms-2"}> + <Button onClick={() => this.zoomIn()} variant={"primary"} size={"sm"}><i className="fa-solid fa-magnifying-glass-plus"></i></Button> + <Button onClick={() => this.zoomOut()} variant={"primary"} size={"sm"}><i className="fa-solid fa-magnifying-glass-minus"></i></Button> + <Button onClick={() => this.zoomFit()} variant={"primary"} size={"sm"}><i className="fa-regular fa-image"></i></Button> + <Button onClick={() => this.zoomNone()} variant={"primary"} size={"sm"}>1:1</Button> + </ButtonGroup> + <Form.Label className={"ms-3"}>[CTRL] + <i className="fa-solid fa-computer-mouse"></i> = <i className="fa-solid fa-arrows-up-down-left-right"></i></Form.Label> + </Form.Group> + </Form> + <div> + <Button href={"docs/index.html"} target="_blank" variant={"primary"} size={"sm"}>Tutoriel<i className="ms-2 fa-solid fa-book"></i></Button> + </div> + </div> + </Row> + </Container> + </Navbar> + <Row className="flex-grow-1"> + <div className="col position-relative"> + <Welcome ref={this.welcome}/> + <canvas ref={(canvas : HTMLCanvasElement)=> this.canvas = canvas} data-paper-resize="false" className="h-100 w-100 d-block" onContextMenu={() => false}></canvas> + </div> + <Col md={4} className={"border-2"} > + <div className="mb-3"> + <StepManager> + <LoadPictureStep lab={this} code="loadPicture" title="Charger une photo"/> + <RulerStep lab={this} code="placeRuler" title="Positionner la règle"/> + <PlacePetriDishStep lab={this} code="placePetriDish" title="Positionner la boîte de petri"/> + <DrawBlobMaskStep lab={this} code="drawBlobMask" title="Détourer le blob"/> + <DownloadStep lab={this} code="download" title="Télécharger les résultats"/> + </StepManager> + </div> + </Col> + </Row> + </Container> + } } \ No newline at end of file diff --git a/src/ui/merger.tsx b/src/ui/merger.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4733e817021b6ff763f2a4a7cce104140f4ac6cf --- /dev/null +++ b/src/ui/merger.tsx @@ -0,0 +1,148 @@ +import * as React from "react"; +import {ChangeEvent} from "react"; +import {Alert, Button, Col, InputGroup, ListGroup, Modal, Row} from "react-bootstrap"; +import {IoUtils} from "../utils/ioUtils"; +import {MeasurementImport} from "../data/measurement/process/measurementImport"; +import {ExperienceEnum, GroupeEnum} from "../data/work/workInfo"; +import {DownloadButton} from "./steps/downloadStep"; +import {MeasurementWorks} from "../data/measurement/measurementWorks"; +import {MeasurementMerge} from "../data/measurement/process/measurementMerge"; +import {MeasurementExport} from "../data/measurement/process/measurementExport"; +import {Measurement} from "../data/measurement/measurement"; + +/** + * L'état de l'affichage de l'outil de merge + */ +export interface MergerState { + visible : boolean, + measurementWorks: MeasurementWorks +} + +/** + * Outil de merge des fichiers de mesures + */ +export class Merger extends React.Component<{}, MergerState> { + + /** + * Pour importer des données + */ + private measurementImport = new MeasurementImport(); + + /** + * Pour exporter des données + */ + private measurementExport = new MeasurementExport(); + + /** + * Pour merger les données + */ + private measurementMerge = new MeasurementMerge(); + + public constructor(props: {}) { + super(props); + this.state = {visible: false, measurementWorks: {} } + } + + /** + * Affiche l'outil de merge + */ + public show() { + this.setState({visible: true}); + } + + /** + * Masque l'outil de merge + */ + public hide() { + this.setState({visible: false}); + } + + /** + * + * @private + */ + private addFiles(event : ChangeEvent<HTMLInputElement>) { + if(event.target.files != null) { + for (let file of event.target.files) { + IoUtils.readTextFile(file, text => { + let filename = file.name; + try { + let {work, measurements} = this.measurementImport.readMeasures(filename, text); + let measurementWorks = this.measurementMerge.mergeInto(this.state.measurementWorks, filename, work, measurements); + this.setState({measurementWorks: measurementWorks}); + } catch (error) { + alert(error.message); + } + }); + } + } + return true; + } + + /** + * Téléchargement de la description du blob + */ + private download(workCode: string) : void { + let measurements: Measurement[] = Object.keys(this.state.measurementWorks[workCode].files) + .flatMap(filename => this.state.measurementWorks[workCode].files[filename]); + + let { filename, csv } = this.measurementExport.exportMeasurements(this.state.measurementWorks[workCode].work, measurements); + IoUtils.downloadData(filename, "text/plain;charset=UTF-8", csv); + } + + + public render() { + let that = this; + return <Modal + show={this.state.visible} + backdrop="static" + keyboard={false} + size={"lg"} + onHide={this.hide.bind(this)} + scrollable={true} + > + <Modal.Header closeButton={true} closeVariant={"white"}> + <Modal.Title as={"h6"}>Fusionner les CSV</Modal.Title> + </Modal.Header> + <Modal.Body> + <Alert className={"p-2"} variant={"success"}><b>A la fin de l'analyse d'une séquence de photos </b>, regroupez ici les fichiers .csv générés par photo (par ex.: <em>Results_ExpJ1CrB10.csv</em>) pour générer le fichier attendu dans l'espace blob (par ex.: <em>Results_ExpJ1Cr.csv</em>).</Alert> + <Row> + <Col> + <b>Sélectionner les fichiers en vrac à regrouper :</b> + <div className="mb-3 mt-1"> + <input className="form-control" type="file" accept={".csv"} multiple={true} onChange={(e) => this.addFiles(e)}></input> + </div> + </Col> + </Row> + <Row> + <Col> + <b>Fichiers fusionnés :</b> + <ListGroup> + {Object.keys(this.state.measurementWorks).map((workCode) => { + return <ListGroup.Item key={workCode}> + <div className={"mb-2"}> + <span className={"ms-0"}><span className={"small text-dark"}>Groupe : </span><b>{this.state.measurementWorks[workCode].work.groupe == GroupeEnum.Experimental ? "Expérimental" : "Contrôle"}</b></span> + <span className={"ms-2"}><span className={"small text-dark"}>Jour : </span><b>{this.state.measurementWorks[workCode].work.jour}</b></span> + <span className={"ms-2"}><span className={"small text-dark"}>Expérience : </span><b>{this.state.measurementWorks[workCode].work.experience == ExperienceEnum.Exploration ? "Exploration" : "Croissance"}</b></span> + </div> + <div className={"mb-2"}> + { Object.keys(this.state.measurementWorks[workCode].files).map(value =><span key={value} className={"me-1 p-1 rounded border bg-light font-monospace"}>{value}</span>)} + </div> + <div className={"col-5"}> + <InputGroup size={"sm"}> + <DownloadButton downloading={false} disabled={false} onClick={() => {this.download(workCode)}}/> + <InputGroup.Text className={"col"}>{ that.measurementExport.exportDryMeasures(this.state.measurementWorks[workCode].work).filename }</InputGroup.Text> + </InputGroup> + </div> + </ListGroup.Item> + })} + </ListGroup> + </Col> + </Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={this.hide.bind(this)}>Fermer</Button> + </Modal.Footer> + </Modal> + } +} \ No newline at end of file diff --git a/src/ui/steps/downloadStep.tsx b/src/ui/steps/downloadStep.tsx index 8effb924298103b047abe2c06ed60e86de8e60bc..804b73319ddc8c71abb32a86ec23d0eb850800d4 100644 --- a/src/ui/steps/downloadStep.tsx +++ b/src/ui/steps/downloadStep.tsx @@ -1,175 +1,206 @@ -import {Step, StepProps, StepState} from "./step"; -import * as React from "react"; -import {Alert, Button, InputGroup} from "react-bootstrap"; -import {IoUtils} from "../../utils/ioUtils"; -import {DataExporter} from "../../data/dataExporter"; -import * as paper from "paper"; -import {ImageDataWrapper, Rgba} from "../../render/ImageDataWrapper"; - -export interface DownloadButtonProps { - downloading: boolean, - onClick : () => void, - disabled: boolean, -} - -export class DownloadButton extends React.Component<DownloadButtonProps, any> { - - constructor(props : DownloadButtonProps) { - super(props); - } - - render() : React.ReactNode { - let faIcon = this.props.downloading ? "fa-solid fas fa-cog fa-spin" : "fa-solid fa-download"; - let key = this.props.downloading ? "downloading" : "notDownloading"; - return <Button key={key} onClick={this.props.onClick} disabled={this.props.disabled || this.props.downloading} variant={"primary"} size={"sm"}> - <i className={faIcon}></i> - </Button> - } - -} - -export interface DownloadStepState extends StepState { - petriDishDataFilename: string | null, - blobMaskDataFilename: string | null, - blobMaskFilename: string | null, - resultsFilename: string | null, - downloading: boolean -} - - - -/** - * Etape de téléchargement des fichiers - */ -export class DownloadStep extends Step<DownloadStepState> { - - /** - * Outil d'export de données - */ - private dataExporter: DataExporter = new DataExporter(); - - public constructor(props: StepProps) { - super(props, { active: false, activable: false, petriDishDataFilename : null, blobMaskDataFilename: null, blobMaskFilename: null, resultsFilename: null, downloading: false }); - } - - canBeActivated(): boolean { - return this.props.lab.data != null - && this.props.lab.ruler.hasBeenStarted() - && this.props.lab.petriDish.hasBeenStarted() - && this.props.lab.blobMask.hasBeenStarted() - && this.props.lab.data.blobMaskCoords.isClosed(); - } - - onActivation(): void { - this.setState({ - petriDishDataFilename: IoUtils.basename(this.props.lab.data.filename) + "_Coord_Boite.txt", - blobMaskDataFilename: IoUtils.basename(this.props.lab.data.filename) + "_Coord_Blob.txt", - blobMaskFilename: IoUtils.basename(this.props.lab.data.filename) + "_Mask.png", - resultsFilename: "Results_" + IoUtils.basename(this.props.lab.data.filename) + ".csv", - }); - } - - onDeactivation(): void { - } - - /** - * Téléchargement des données de la boîte de Petri - */ - private downloadPetriDishData() : void { - let data = this.dataExporter.exportPathPointsAsXYCsv(this.props.lab.data.petriDishCoords, true); - IoUtils.downloadData(this.state.petriDishDataFilename, "text/plain;charset=UTF-8", data); - } - - /** - * Téléchargement des données du mask - */ - private downloadBlobMaskData() : void { - let data = this.dataExporter.exportPathSegmentsAsXYCsv(this.props.lab.data.blobMaskCoords, true); - IoUtils.downloadData(this.state.blobMaskDataFilename, "text/plain;charset=UTF-8", data); - } - - /** - * Téléchargement des données du mask - */ - private downloadBlobMask() : void { - let path = this.props.lab.data.blobMaskCoords.toRemovedPath(); - path.closed = true; - path.fillColor = new paper.Color("black"); - path.strokeColor = null; - path.scale(1 / paper.view.pixelRatio, path.bounds.point); - - let raster = path.rasterize({ insert: false}); - raster.smoothing = "off"; - - let newCanvas = document.createElement('canvas'); - let w = this.props.lab.data.pictureSize.width; - newCanvas.width = w; - let h = this.props.lab.data.pictureSize.height; - newCanvas.height = h; - - var newContext = newCanvas.getContext('2d'); - newContext.fillStyle = "white"; - newContext.fillRect(0, 0, newCanvas.width, newCanvas.height); - newContext.drawImage(raster.canvas, path.bounds.x - 0.5, path.bounds.y - 0.5); - - // Re-aliasing - const imageDataWrapper = new ImageDataWrapper(newContext.getImageData(0, 0, w, h)); - - // Supprime les nuances de gris en se basant sur la valeur rouge (on est sur d'avoir du gris) - imageDataWrapper.apply((rgba: Rgba) => { - let l = rgba.r >= 128 ? 255 : 0; - // let l = rgba.r == 255 ? 255 : 0; - return {r: l, g: l, b: l, a: 255}; - }).andStore(newContext); - - IoUtils.downloadDataUrl(this.state.blobMaskFilename, newCanvas.toDataURL("image/png")); - - newCanvas.remove(); - } - - /** - * Téléchargement de la description du blob - */ - private downloadResults() : void { - this.downloading( () => { - let data = this.dataExporter.exportPathDescriptorsAsCsv(this.props.lab.data, this.props.lab.data.blobMaskCoords); - IoUtils.downloadData(this.state.resultsFilename, "text/plain;charset=UTF-8", data); - }); - } - - /** - * Execute une tâche en indiquant un chargement en cours - */ - private downloading(task : () => void) { - this.setState({downloading : true}, () => { - setTimeout(() => { - task(); - this.setState({downloading: false}); - },500); - }); - } - - render() : React.ReactNode { - return <div> - <Alert show={!this.state.activable} variant="warning" className={"p-1"}>Veuillez terminer les étapes précédentes.</Alert> - <p>Téléchargez les fichiers d'analyse de la photo.</p> - <InputGroup className="mb-1"> - <DownloadButton key="downloadPetriDishDataButtonKey" downloading={false} onClick={this.downloadPetriDishData.bind(this)} disabled={!this.state.active}/> - <InputGroup.Text className={"col"}>{ this.state.petriDishDataFilename }</InputGroup.Text> - </InputGroup> - <InputGroup className="mb-1"> - <DownloadButton key="downloadBlobMaskDataButtonKey" downloading={false} onClick={this.downloadBlobMaskData.bind(this)} disabled={!this.state.active}/> - <InputGroup.Text className={"col"}>{ this.state.blobMaskDataFilename }</InputGroup.Text> - </InputGroup> - <InputGroup className="mb-1"> - <DownloadButton key="downloadBlobMaskButtonKey" downloading={false} onClick={this.downloadBlobMask.bind(this)} disabled={!this.state.active}/> - <InputGroup.Text className={"col"}>{ this.state.blobMaskFilename }</InputGroup.Text> - </InputGroup> - <InputGroup className="mb-1"> - <DownloadButton key="downloadResultsButtonKey" downloading={this.state.downloading} onClick={this.downloadResults.bind(this)} disabled={!this.state.active}></DownloadButton> - <InputGroup.Text className ={"col"}>{ this.state.resultsFilename }</InputGroup.Text> - </InputGroup> - </div> - } - -} +import {Step, StepProps, StepState} from "./step"; +import * as React from "react"; +import {Alert, Anchor, Button, Col, InputGroup, Row} from "react-bootstrap"; +import {IoUtils} from "../../utils/ioUtils"; +import {DataExporter} from "../../data/dataExporter"; +import * as paper from "paper"; +import {ImageDataWrapper, Rgba} from "../../render/ImageDataWrapper"; +import {Merger} from "../merger"; +import {WorkInfoFormatter} from "../../data/work/process/workInfoFormatter"; +import {MeasurementExport} from "../../data/measurement/process/measurementExport"; + +export interface DownloadButtonProps { + downloading: boolean, + onClick : () => void, + disabled: boolean, +} + +export class DownloadButton extends React.Component<DownloadButtonProps, any> { + + constructor(props : DownloadButtonProps) { + super(props); + } + + render() : React.ReactNode { + let faIcon = this.props.downloading ? "fa-solid fas fa-cog fa-spin" : "fa-solid fa-download"; + let key = this.props.downloading ? "downloading" : "notDownloading"; + return <Button key={key} onClick={this.props.onClick} disabled={this.props.disabled || this.props.downloading} variant={"primary"} size={"sm"}> + <i className={faIcon}></i> + </Button> + } + +} + +export interface DownloadStepState extends StepState { + petriDishDataFilename: string | null, + blobMaskDataFilename: string | null, + blobMaskFilename: string | null, + resultsFilename: string | null, + downloading: boolean +} + + + +/** + * Etape de téléchargement des fichiers + */ +export class DownloadStep extends Step<DownloadStepState> { + + /** + * Accès au dialog d'outil de merge + * @private + */ + private mergerRef = React.createRef<Merger>(); + + /** + * Outil d'export de données + */ + private dataExporter = new DataExporter(); + + /** + * Outil d'export de données + */ + private measureExport = new MeasurementExport(); + + public constructor(props: StepProps) { + super(props, { active: false, activable: false, petriDishDataFilename : null, blobMaskDataFilename: null, blobMaskFilename: null, resultsFilename: null, downloading: false }); + } + + canBeActivated(): boolean { + return this.props.lab.data != null + && this.props.lab.ruler.hasBeenStarted() + && this.props.lab.petriDish.hasBeenStarted() + && this.props.lab.blobMask.hasBeenStarted() + && this.props.lab.data.blobMaskCoords.isClosed(); + } + + onActivation(): void { + this.setState({ + petriDishDataFilename: IoUtils.basename(this.props.lab.data.filename) + "_Coord_Boite.txt", + blobMaskDataFilename: IoUtils.basename(this.props.lab.data.filename) + "_Coord_Blob.txt", + blobMaskFilename: IoUtils.basename(this.props.lab.data.filename) + "_Mask.png", + resultsFilename: this.measureExport.exportDryMeasures(this.props.lab.data.workInfo).filename, // dry run juste pour le nom + }); + } + + onDeactivation(): void { + } + + /** + * Téléchargement des données de la boîte de Petri + */ + private downloadPetriDishData() : void { + let data = this.dataExporter.exportPathPointsAsXYCsv(this.props.lab.data.petriDishCoords, true); + IoUtils.downloadData(this.state.petriDishDataFilename, "text/plain;charset=UTF-8", data); + } + + /** + * Téléchargement des données du mask + */ + private downloadBlobMaskData() : void { + let data = this.dataExporter.exportPathSegmentsAsXYCsv(this.props.lab.data.blobMaskCoords, true); + IoUtils.downloadData(this.state.blobMaskDataFilename, "text/plain;charset=UTF-8", data); + } + + /** + * Téléchargement des données du mask + */ + private downloadBlobMask() : void { + let path = this.props.lab.data.blobMaskCoords.toRemovedPath(); + path.closed = true; + path.fillColor = new paper.Color("black"); + path.strokeColor = null; + path.scale(1 / paper.view.pixelRatio, path.bounds.point); + + let raster = path.rasterize({ insert: false}); + raster.smoothing = "off"; + + let newCanvas = document.createElement('canvas'); + let w = this.props.lab.data.pictureSize.width; + newCanvas.width = w; + let h = this.props.lab.data.pictureSize.height; + newCanvas.height = h; + + var newContext = newCanvas.getContext('2d'); + newContext.fillStyle = "white"; + newContext.fillRect(0, 0, newCanvas.width, newCanvas.height); + newContext.drawImage(raster.canvas, path.bounds.x - 0.5, path.bounds.y - 0.5); + + // Re-aliasing + const imageDataWrapper = new ImageDataWrapper(newContext.getImageData(0, 0, w, h)); + + // Supprime les nuances de gris en se basant sur la valeur rouge (on est sur d'avoir du gris) + imageDataWrapper.apply((rgba: Rgba) => { + let l = rgba.r >= 128 ? 255 : 0; + // let l = rgba.r == 255 ? 255 : 0; + return {r: l, g: l, b: l, a: 255}; + }).andStore(newContext); + + IoUtils.downloadDataUrl(this.state.blobMaskFilename, newCanvas.toDataURL("image/png")); + + newCanvas.remove(); + } + + /** + * Téléchargement de la description du blob + */ + private downloadResults() : void { + this.downloading( () => { + let data = this.dataExporter.exportPathDescriptorsAsCsv(this.props.lab.data); + IoUtils.downloadData(this.state.resultsFilename, "text/plain;charset=UTF-8", data); + }); + } + + /** + * Ouvre l'outil de merge des fichiers CSV + * @private + */ + private openMerger() { + this.mergerRef.current.show(); + } + + /** + * Execute une tâche en indiquant un chargement en cours + */ + private downloading(task : () => void) { + this.setState({downloading : true}, () => { + setTimeout(() => { + task(); + this.setState({downloading: false}); + },500); + }); + } + + + + render() : React.ReactNode { + return <div> + <Alert show={!this.state.activable} variant="warning" className={"p-1"}>Veuillez terminer les étapes précédentes.</Alert> + <p>Téléchargez les fichiers d'analyse de la photo.</p> + <InputGroup className="mb-1"> + <DownloadButton key="downloadPetriDishDataButtonKey" downloading={false} onClick={this.downloadPetriDishData.bind(this)} disabled={!this.state.active}/> + <InputGroup.Text className={"col"}>{ this.state.petriDishDataFilename }</InputGroup.Text> + </InputGroup> + <InputGroup className="mb-1"> + <DownloadButton key="downloadBlobMaskDataButtonKey" downloading={false} onClick={this.downloadBlobMaskData.bind(this)} disabled={!this.state.active}/> + <InputGroup.Text className={"col"}>{ this.state.blobMaskDataFilename }</InputGroup.Text> + </InputGroup> + <InputGroup className="mb-1"> + <DownloadButton key="downloadBlobMaskButtonKey" downloading={false} onClick={this.downloadBlobMask.bind(this)} disabled={!this.state.active}/> + <InputGroup.Text className={"col"}>{ this.state.blobMaskFilename }</InputGroup.Text> + </InputGroup> + <InputGroup className="mb-1"> + <DownloadButton key="downloadResultsButtonKey" downloading={this.state.downloading} onClick={this.downloadResults.bind(this)} disabled={!this.state.active}></DownloadButton> + <InputGroup.Text className ={"col"}>{ this.state.resultsFilename }</InputGroup.Text> + </InputGroup> + <Row className="mb-1"> + <Col> + <Button key="mergerButtonKey" onClick={this.openMerger.bind(this)}><i className="fa-solid fa-object-group me-2"></i>Etape 5' : fusionner les CSV</Button> + <a className={"ms-1"} href={"http://localhost:8081/docs/index.html#/"} target={"_blank"}>en savoir plus</a> + </Col> + </Row> + <Merger ref={this.mergerRef}></Merger> + </div> + } + +} diff --git a/src/ui/steps/drawBlobMaskStep.tsx b/src/ui/steps/drawBlobMaskStep.tsx index b8d654f41a356dc679cb96108d7ddf7fbafc5500..64e514f7541bbb3f5fd640b829e329c41f6ad572 100644 --- a/src/ui/steps/drawBlobMaskStep.tsx +++ b/src/ui/steps/drawBlobMaskStep.tsx @@ -1,79 +1,79 @@ -import {Step, StepProps, StepState} from "./step"; -import * as React from "react"; -import * as paper from "paper"; -import {Alert, Button} from "react-bootstrap"; -import {IoUtils} from "../../utils/ioUtils"; -import {DEBUG_MODE} from "../../lab"; -import {StringUtils} from "../../utils/stringUtils"; -import {DataImporter} from "../../data/dataImporter"; - - -interface DrawBlobMaskStepState extends StepState { - - closed: boolean; - -} - -/** - * Étape de placement de la boîte de petri - */ -export class DrawBlobMaskStep extends Step<DrawBlobMaskStepState> { - - public constructor(props : StepProps) { - super(props, { active: false, activable : false, closed : false }); - } - - canBeActivated(): boolean { - return this.props.lab.data != null; - } - - onActivation(): void { - this.props.lab.blobMask.activate(); - this.setState({closed: this.props.lab.blobMask.isClosed() }); - this.props.lab.blobMask.onClose = () => { - this.setState({closed: true }); - }; - this.props.lab.blobMask.onOpen = () => { - this.setState({closed: false }); - }; - this.props.lab.zoomOn( this.props.lab.data.petriDishCoords.bounds(), 0.05); - } - - onDeactivation(): void { - this.props.lab.blobMask.deactivate(); - } - - loadData(): void { - let dataImporter = new DataImporter(); - IoUtils.openTextFile( - (text : string) => { - this.props.lab.data.blobMaskCoords = dataImporter.import(text); - this.props.lab.blobMask.refresh(); - }); - } - - render() : React.ReactNode { - return <div> - <div> - <Alert show={!this.state.activable} variant="warning" className={"p-1"}><i className="ms-1 me-1 fa-solid fa-triangle-exclamation"></i>Veuillez charger une photo.</Alert> - <p>Entourez le blob <span className={"fw-bold"}>jusqu'à rejoindre le point de départ</span>.</p> - <Alert variant={"light"} className={"p-2"}><i className="ms-1 me-1 fa-solid fa-circle-info"></i>Un faux mouvement ? <br/> - Revenir en arrière : <Button className={"me-2"} size={"sm"} disabled={!this.state.active} onClick={() => this.props.lab.blobMask.undo()}><i className="fa-solid fa-delete-left"></i></Button> - Tout effacer : <Button className={"me-2"} size={"sm"} disabled={!this.state.active} onClick={() => this.props.lab.blobMask.undoAll()}><i className="fa-solid fa-trash-can"></i></Button> - </Alert> - <Button className={"col-3"} variant={"success"} disabled={!this.state.active || !this.state.closed} onClick={this.terminate.bind(this)}> - <span hidden={!this.state.closed}><i className="fa-solid fa-hands-clapping fa-beat-fade me-2"></i></span>Fini ! - </Button> - {DEBUG_MODE ? - <Button className={"ms-2 col-5"} variant={"danger"} disabled={!this.state.active} - onClick={this.loadData.bind(this)}> - <i className="fa-solid fa-bug"></i> Charger XY - </Button> - : <></> - } - </div> - </div> - } - -} - +import {Step, StepProps, StepState} from "./step"; +import * as React from "react"; +import * as paper from "paper"; +import {Alert, Button} from "react-bootstrap"; +import {IoUtils} from "../../utils/ioUtils"; +import {DEBUG_MODE} from "../../lab"; +import {StringUtils} from "../../utils/stringUtils"; +import {DataImporter} from "../../data/dataImporter"; + + +interface DrawBlobMaskStepState extends StepState { + + closed: boolean; + +} + +/** + * Étape de placement de la boîte de petri + */ +export class DrawBlobMaskStep extends Step<DrawBlobMaskStepState> { + + public constructor(props : StepProps) { + super(props, { active: false, activable : false, closed : false }); + } + + canBeActivated(): boolean { + return this.props.lab.data != null; + } + + onActivation(): void { + this.props.lab.blobMask.activate(); + this.setState({closed: this.props.lab.blobMask.isClosed() }); + this.props.lab.blobMask.onClose = () => { + this.setState({closed: true }); + }; + this.props.lab.blobMask.onOpen = () => { + this.setState({closed: false }); + }; + this.props.lab.zoomOn( this.props.lab.data.petriDishCoords.bounds(), 0.05); + } + + onDeactivation(): void { + this.props.lab.blobMask.deactivate(); + } + + loadData(): void { + let dataImporter = new DataImporter(); + IoUtils.openTextFile( + (text : string) => { + this.props.lab.data.blobMaskCoords = dataImporter.readPathCoords(text); + this.props.lab.blobMask.refresh(); + }); + } + + render() : React.ReactNode { + return <div> + <div> + <Alert show={!this.state.activable} variant="warning" className={"p-1"}><i className="ms-1 me-1 fa-solid fa-triangle-exclamation"></i>Veuillez charger une photo.</Alert> + <p>Entourez le blob <span className={"fw-bold"}>jusqu'à rejoindre le point de départ</span>.</p> + <Alert variant={"light"} className={"p-2"}><i className="ms-1 me-1 fa-solid fa-circle-info"></i>Un faux mouvement ? <br/> + Revenir en arrière : <Button className={"me-2"} size={"sm"} disabled={!this.state.active} onClick={() => this.props.lab.blobMask.undo()}><i className="fa-solid fa-delete-left"></i></Button> + Tout effacer : <Button className={"me-2"} size={"sm"} disabled={!this.state.active} onClick={() => this.props.lab.blobMask.undoAll()}><i className="fa-solid fa-trash-can"></i></Button> + </Alert> + <Button className={"col-3"} variant={"success"} disabled={!this.state.active || !this.state.closed} onClick={this.terminate.bind(this)}> + <span hidden={!this.state.closed}><i className="fa-solid fa-hands-clapping fa-beat-fade me-2"></i></span>Fini ! + </Button> + {DEBUG_MODE ? + <Button className={"ms-2 col-5"} variant={"danger"} disabled={!this.state.active} + onClick={this.loadData.bind(this)}> + <i className="fa-solid fa-bug"></i> Charger XY + </Button> + : <></> + } + </div> + </div> + } + +} + diff --git a/src/ui/steps/loadPictureStep.tsx b/src/ui/steps/loadPictureStep.tsx index f614a4ac32ddac039dcb3319e2925847a42597ce..ff7d9ff3d8a4d39dd807677c0c98b683e0114c15 100644 --- a/src/ui/steps/loadPictureStep.tsx +++ b/src/ui/steps/loadPictureStep.tsx @@ -1,49 +1,49 @@ -import {Step, StepProps, StepState} from "./step"; -import * as React from "react"; -import {ChangeEvent} from "react"; - -/** - * Etape de chargement du fichier - */ -export class LoadPictureStep extends Step<StepState> { - - public constructor(props : StepProps) { - super(props, { active: true, activable : false }); - } - - private fileChanged(event : ChangeEvent<HTMLInputElement>) : boolean { - if(event.target.files != null) { - const img = new Image(); - const file = event.target.files[0]; - img.src = URL.createObjectURL(file); - let filename = file.name; - let that = this; - img.onload = function () { - if(that.props.lab.new(img, filename)) { - that.terminate(); - } - } - } - return true; - } - - canBeActivated(): boolean { - return true; - } - - onActivation(): void { - } - - onDeactivation(): void { - } - - render() : React.ReactNode { - return <div> - <div className="mb-3"> - <input className="form-control" type="file" disabled={!this.state.active} aria-disabled={!this.state.active} onChange={(e) => this.fileChanged(e)}></input> - </div> - </div> - } - - +import {Step, StepProps, StepState} from "./step"; +import * as React from "react"; +import {ChangeEvent} from "react"; + +/** + * Etape de chargement du fichier + */ +export class LoadPictureStep extends Step<StepState> { + + public constructor(props : StepProps) { + super(props, { active: true, activable : false }); + } + + private fileChanged(event : ChangeEvent<HTMLInputElement>) : boolean { + if(event.target.files != null) { + const img = new Image(); + const file = event.target.files[0]; + img.src = URL.createObjectURL(file); + let filename = file.name; + let that = this; + img.onload = function () { + if(that.props.lab.new(img, filename)) { + that.terminate(); + } + } + } + return true; + } + + canBeActivated(): boolean { + return true; + } + + onActivation(): void { + } + + onDeactivation(): void { + } + + render() : React.ReactNode { + return <div> + <div className="mb-3"> + <input className="form-control" type="file" accept={"image/*"} disabled={!this.state.active} aria-disabled={!this.state.active} onChange={(e) => this.fileChanged(e)}></input> + </div> + </div> + } + + } \ No newline at end of file diff --git a/src/utils/ioUtils.ts b/src/utils/ioUtils.ts index a955a0a6225eab1074f46a9b98358552e683d606..e944ad4717c71d01d32293a92d879c38ef24f765 100644 --- a/src/utils/ioUtils.ts +++ b/src/utils/ioUtils.ts @@ -1,55 +1,61 @@ -/** - * Une classe utilitaire pour les entrées/sorties - */ -export class IoUtils { - - /** - * Ouvre un chargement - */ - public static openTextFile(onTextLoad : (text: string) => void) : void { - let input : HTMLInputElement = document.createElement('input'); - input.type = 'file'; - input.accept = '.txt'; - input.onchange = (e: InputEvent) => { - // getting a hold of the file reference - var file = (e.target as HTMLInputElement).files[0]; - - // setting up the reader - let reader = new FileReader(); - reader.readAsText(file,'UTF-8'); - // here we tell the reader what to do when it's done reading... - reader.onload = readerEvent => { - onTextLoad(readerEvent.target.result as string); - } - - } - input.click(); - } - - /** - * Donne le nom de fichier sans extension - */ - public static basename(filename : string) : string { - return filename.replace(/\.[^/.]+$/, ""); - } - - /** - * Déclenche un téléchargement - */ - public static downloadData(filename : string, mimeType : string, data : string) { - this.downloadDataUrl(filename, "data:" + mimeType + "," + encodeURIComponent(data)); - } - - /** - * Déclenche un téléchargement - */ - public static downloadDataUrl(filename : string, dataUrl : string) { - let anchor = document.createElement("a"); - anchor.href = dataUrl; - anchor.download = filename; - anchor.style.display = 'none'; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - } +/** + * Une classe utilitaire pour les entrées/sorties + */ +export class IoUtils { + + /** + * Ouvre un chargement + */ + public static openTextFile(onTextLoad : (text: string) => void) : void { + let input : HTMLInputElement = document.createElement('input'); + input.type = 'file'; + input.accept = '.txt'; + input.onchange = (e: InputEvent) => { + // getting a hold of the file reference + var file = (e.target as HTMLInputElement).files[0]; + + // setting up the reader + this.readTextFile(file, onTextLoad) + + } + input.click(); + } + + /** + * Lit le texte dans un fichier + */ + public static readTextFile(file: Blob, onTextLoad : (text: string) => void) : void { + let reader = new FileReader(); + reader.readAsText(file,'UTF-8'); + // here we tell the reader what to do when it's done reading... + reader.onload = readerEvent => { + onTextLoad(readerEvent.target.result as string); + } + } + /** + * Donne le nom de fichier sans extension + */ + public static basename(filename : string) : string { + return filename.replace(/\.[^/.]+$/, ""); + } + + /** + * Déclenche un téléchargement + */ + public static downloadData(filename : string, mimeType : string, data : string) { + this.downloadDataUrl(filename, "data:" + mimeType + "," + encodeURIComponent(data)); + } + + /** + * Déclenche un téléchargement + */ + public static downloadDataUrl(filename : string, dataUrl : string) { + let anchor = document.createElement("a"); + anchor.href = dataUrl; + anchor.download = filename; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + } } \ No newline at end of file diff --git a/src/utils/objectUtils.ts b/src/utils/objectUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..1560063722b1a9de9964d25705e739dd1f8d8376 --- /dev/null +++ b/src/utils/objectUtils.ts @@ -0,0 +1,13 @@ +/** + * Utilitaire autour des objets + */ +export class ObjectUtils { + + /** + * Deep clonin + */ + public static deepClone<T>(obj: T) : T{ + return JSON.parse(JSON.stringify(obj)) as T; + } + +} \ No newline at end of file diff --git a/tests/data/dataExporter.test.ts b/tests/data/dataExporter.test.ts index 6df1b56df30a8ba923eb89ccf0187c30735280aa..03306796693153cb2a83be50cf0bfd8b7a71d4e4 100644 --- a/tests/data/dataExporter.test.ts +++ b/tests/data/dataExporter.test.ts @@ -1,29 +1,31 @@ -import {DataExporter} from "../../src/data/dataExporter"; -import {Fixtures} from "../fixtures/fixtures"; -import expectedResultsCsv from "../fixtures/Results_ExpJ1CrB9.csv"; - -describe('Testing DataExporter...', () => { - - /** - * Class under test - */ - let dataExporter = new DataExporter(); - - it('CSV metrics are correct', () => { - let labData = Fixtures.labData(); - let descriptorsCsv = dataExporter.exportPathDescriptorsAsCsv(labData, labData.blobMaskCoords); - expect(descriptorsCsv).toEqual(expectedResultsCsv); - }); - - it('Tick count may vary along with the ruler length', () => { - let labData = Fixtures.labData(); - - labData.rulerTickCount = 8; - labData.rulerCoords.end = labData.rulerCoords.start.add( - labData.rulerCoords.end.subtract(labData.rulerCoords.start).multiply(8).divide(10) - ) - - let descriptorsCsv = dataExporter.exportPathDescriptorsAsCsv(labData, labData.blobMaskCoords); - expect(descriptorsCsv).toEqual(expectedResultsCsv); - }); +import {DataExporter} from "../../src/data/dataExporter"; +import {Fixtures} from "../fixtures/fixtures"; +import expectedResultsCsv from "../fixtures/Results_ExpJ1CrB9.csv"; + +describe('Testing DataExporter...', () => { + + /** + * Class under test + */ + let dataExporter = new DataExporter(); + + it('CSV metrics are correct', () => { + let labData = Fixtures.labData(); + let descriptorsCsv = dataExporter.exportPathDescriptorsAsCsv(labData); + expect(descriptorsCsv).toEqual(expectedResultsCsv); + }); + + it('Tick count may vary along with the ruler length', () => { + let labData = Fixtures.labData(); + + labData.rulerTickCount = 8; + labData.rulerCoords.end = labData.rulerCoords.start.add( + labData.rulerCoords.end.subtract(labData.rulerCoords.start).multiply(8).divide(10) + ) + + let descriptorsCsv = dataExporter.exportPathDescriptorsAsCsv(labData); + expect(descriptorsCsv).toEqual(expectedResultsCsv); + }); + + }); \ No newline at end of file diff --git a/tests/data/measurement/process/measurementImport.test.ts b/tests/data/measurement/process/measurementImport.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..60d1eb00d182ce025e18f97d0ac76f39520a9e8c --- /dev/null +++ b/tests/data/measurement/process/measurementImport.test.ts @@ -0,0 +1,72 @@ +import {WorkInfoFormatter} from "../../../../src/data/work/process/workInfoFormatter"; +import {ExperienceEnum, GroupeEnum} from "../../../../src/data/work/workInfo"; +import {ObjectUtils} from "../../../../src/utils/objectUtils"; +import {MeasurementImport} from "../../../../src/data/measurement/process/measurementImport"; +import resultsExpJ1CrB9Csv from "../../../fixtures/Results_ExpJ1CrB9.csv"; +import resultsConJ11ExCsv from "../../../fixtures/Results_ConJ11Ex.csv"; + +describe('Testing Measurement import...', () => { + + /** + * Class under test + */ + let measurementImport = new MeasurementImport(); + + it('Mono blob is well imported ', () => { + let data = measurementImport.readMeasures("Results_ExpJ1CrB9.csv", resultsExpJ1CrB9Csv); + expect(data.work.groupe).toEqual(GroupeEnum.Experimental); + expect(data.work.jour).toEqual(1); + expect(data.work.experience).toEqual(ExperienceEnum.Croissance); + expect(data.work.blob).toEqual(9); + + expect(data.measurements).toHaveSize(1); + expect(data.measurements[0]).toEqual({ + label: "ExpJ1CrB9.jpg" , + area: 33.4, + perimeter: 33.93, + circularity: 0.365, + ar: 1.625, + round: 0.615, + solid: 0.871 + }); + }); + + it('Multi blob is well imported ', () => { + let data = measurementImport.readMeasures("Results_ConJ11Ex.csv", resultsConJ11ExCsv); + expect(data.work.groupe).toEqual(GroupeEnum.Controle); + expect(data.work.jour).toEqual(11); + expect(data.work.experience).toEqual(ExperienceEnum.Exploration); + expect(data.work.blob).toBeNull(); + + expect(data.measurements).toHaveSize(3); + expect(data.measurements[0]).toEqual({ + label: "ConJ11ExB1.jpg" , + area: 11.22, + perimeter: 54.93, + circularity: 1.212, + ar: 0.4, + round: 0.4, + solid: 0.271 + }); + expect(data.measurements[1]).toEqual({ + label: "ConJ11ExB2.jpg" , + area: 33.4, + perimeter: 33.93, + circularity: 0.365, + ar: 1.625, + round: 0.615, + solid: 0.871 + }); + expect(data.measurements[2]).toEqual({ + label: "ConJ11ExB9.jpg" , + area: 15.9, + perimeter: 55.87, + circularity: 0.6, + ar: 1.98, + round: 0.1, + solid: 0.45 + }); + + }); + +}); \ No newline at end of file diff --git a/tests/data/measurement/process/measurementMerge.test.ts b/tests/data/measurement/process/measurementMerge.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0db5eccd86cf958a3b01afd498c87ab9e4c4504e --- /dev/null +++ b/tests/data/measurement/process/measurementMerge.test.ts @@ -0,0 +1,88 @@ +import {ExperienceEnum, GroupeEnum} from "../../../../src/data/work/workInfo"; +import {MeasurementImport} from "../../../../src/data/measurement/process/measurementImport"; +import resultsExpJ1CrB9Csv from "../../../fixtures/Results_ExpJ1CrB9.csv"; +import resultsConJ11ExB1Csv from "../../../fixtures/Results_ConJ11ExB1.csv"; +import resultsConJ11ExB2Csv from "../../../fixtures/Results_ConJ11ExB2.csv"; +import resultsConJ11ExB9Csv from "../../../fixtures/Results_ConJ11ExB9.csv"; +import {MeasurementMerge} from "../../../../src/data/measurement/process/measurementMerge"; +import {MeasurementWorks} from "../../../../src/data/measurement/measurementWorks"; + +describe('Testing Measurement merge...', () => { + + /** + * Class under test + */ + let measurementMerge = new MeasurementMerge(); + + /** + * Pour d'abord importer les données + */ + let measurementImport = new MeasurementImport(); + + it('Merge works well', () => { + let measurementWorks: MeasurementWorks = {}; + { + let {work, measurements} = measurementImport.readMeasures("Results_ConJ11ExB9.csv", resultsConJ11ExB9Csv); + measurementWorks = measurementMerge.mergeInto(measurementWorks, "Results_ConJ11ExB9.csv", work, measurements); + } + { + let {work, measurements} = measurementImport.readMeasures("Results_ConJ11ExB1.csv", resultsConJ11ExB1Csv); + measurementWorks = measurementMerge.mergeInto(measurementWorks, "Results_ConJ11ExB1.csv", work, measurements); + } + { + let {work, measurements} = measurementImport.readMeasures("Results_ExpJ1CrB9.csv", resultsExpJ1CrB9Csv); + measurementWorks = measurementMerge.mergeInto(measurementWorks, "Results_ExpJ1CrB9.csv", work, measurements); + } + { + let {work, measurements} = measurementImport.readMeasures("Results_ConJ11ExB2.csv", resultsConJ11ExB2Csv); + measurementWorks = measurementMerge.mergeInto(measurementWorks, "Results_ConJ11ExB2.csv", work, measurements); + } + expect(measurementWorks).toHaveSize(2); + expect(measurementWorks["ExpJ1Cr"].work).toEqual({groupe: GroupeEnum.Experimental, jour: 1, experience: ExperienceEnum.Croissance, blob: null}); + expect(measurementWorks["ExpJ1Cr"].files).toHaveSize(1); + expect(measurementWorks["ExpJ1Cr"].files["Results_ExpJ1CrB9.csv"]).toHaveSize(1); + expect(measurementWorks["ExpJ1Cr"].files["Results_ExpJ1CrB9.csv"][0]).toEqual({ + label: "ExpJ1CrB9.jpg" , + area: 33.4, + perimeter: 33.93, + circularity: 0.365, + ar: 1.625, + round: 0.615, + solid: 0.871 + }); + expect(measurementWorks["ConJ11Ex"].work).toEqual({groupe: GroupeEnum.Controle, jour: 11, experience: ExperienceEnum.Exploration, blob: null}); + expect(measurementWorks["ConJ11Ex"].files).toHaveSize(3); + expect(measurementWorks["ConJ11Ex"].files["Results_ConJ11ExB1.csv"]).toHaveSize(1); + expect(measurementWorks["ConJ11Ex"].files["Results_ConJ11ExB1.csv"][0]).toEqual({ + label: "ConJ11ExB1.jpg" , + area: 11.22, + perimeter: 54.93, + circularity: 1.212, + ar: 0.4, + round: 0.4, + solid: 0.271 + }); + expect(measurementWorks["ConJ11Ex"].files["Results_ConJ11ExB2.csv"]).toHaveSize(1); + expect(measurementWorks["ConJ11Ex"].files["Results_ConJ11ExB2.csv"][0]).toEqual({ + label: "ConJ11ExB2.jpg" , + area: 33.4, + perimeter: 33.93, + circularity: 0.365, + ar: 1.625, + round: 0.615, + solid: 0.871 + }); + expect(measurementWorks["ConJ11Ex"].files["Results_ConJ11ExB9.csv"]).toHaveSize(1); + expect(measurementWorks["ConJ11Ex"].files["Results_ConJ11ExB9.csv"][0]).toEqual({ + label: "ConJ11ExB9.jpg" , + area: 15.9, + perimeter: 55.87, + circularity: 0.6, + ar: 1.98, + round: 0.1, + solid: 0.45 + }); + }); + + +}); \ No newline at end of file diff --git a/tests/data/work/process/workInfoFormatter.test.ts b/tests/data/work/process/workInfoFormatter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a375111e5e1f0e60ae5362b030dfc513a23df028 --- /dev/null +++ b/tests/data/work/process/workInfoFormatter.test.ts @@ -0,0 +1,22 @@ +import {ExperienceEnum, GroupeEnum} from "../../../../src/data/work/workInfo"; +import {WorkInfoParser} from "../../../../src/data/work/process/workInfoParser"; +import {WorkInfoFormatter} from "../../../../src/data/work/process/workInfoFormatter"; +import {ObjectUtils} from "../../../../src/utils/objectUtils"; + +describe('Testing WorkInfoFormatter...', () => { + + /** + * Class under test + */ + let workInfoFormatter = new WorkInfoFormatter(); + + it('Work session formatting is correct', () => { + let workInfo1 = {groupe: GroupeEnum.Controle, jour: 5, experience: ExperienceEnum.Croissance, blob: 22}; + expect(workInfoFormatter.format(workInfo1)).toEqual("ConJ5CrB22"); + + let workInfo2 = ObjectUtils.deepClone(workInfo1); + workInfo2.blob = null; + expect(workInfoFormatter.format(workInfo2)).toEqual("ConJ5Cr"); + }); + +}); \ No newline at end of file diff --git a/tests/data/work/process/workInfoParser.test.ts b/tests/data/work/process/workInfoParser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..98c572e3a7d78e99bbf7a84fa6fa74a54c35c69a --- /dev/null +++ b/tests/data/work/process/workInfoParser.test.ts @@ -0,0 +1,41 @@ +import {ExperienceEnum, GroupeEnum} from "../../../../src/data/work/workInfo"; +import {WorkInfoParser} from "../../../../src/data/work/process/workInfoParser"; + +describe('Testing WorkInfoParser...', () => { + + /** + * Class under test + */ + let workInfoParser = new WorkInfoParser(); + + it('Parsing works well', () => { + let workInfo1 = workInfoParser.parse("ConJ1ExB2"); + expect(workInfo1.groupe).toEqual(GroupeEnum.Controle); + expect(workInfo1.jour).toEqual(1); + expect(workInfo1.experience).toEqual(ExperienceEnum.Exploration); + expect(workInfo1.blob).toEqual(2); + + let workInfo2 = workInfoParser.parse("ExpJ25CrB99"); + expect(workInfo2.groupe).toEqual(GroupeEnum.Experimental); + expect(workInfo2.jour).toEqual(25); + expect(workInfo2.experience).toEqual(ExperienceEnum.Croissance); + expect(workInfo2.blob).toEqual(99); + + let workInfo3 = workInfoParser.parse("conj2crb3"); + expect(workInfo3.groupe).toEqual(GroupeEnum.Controle); + expect(workInfo3.jour).toEqual(2); + expect(workInfo3.experience).toEqual(ExperienceEnum.Croissance); + expect(workInfo3.blob).toEqual(3); + + let workInfo4 = workInfoParser.parse("ExpJ25Cr"); + expect(workInfo4.groupe).toEqual(GroupeEnum.Experimental); + expect(workInfo4.jour).toEqual(25); + expect(workInfo4.experience).toEqual(ExperienceEnum.Croissance); + expect(workInfo4.blob).toBeNull(); + + expect(workInfoParser.parse("ConJ1ExB2.jpg")).toBeNull(); + expect(workInfoParser.parse("ConJExB")).toBeNull(); + expect(workInfoParser.parse("J1ExB2")).toBeNull(); + expect(workInfoParser.parse("DumJ25CrB99")).toBeNull(); + }); +}); \ No newline at end of file diff --git a/tests/fixtures/Results_ConJ11Ex.csv b/tests/fixtures/Results_ConJ11Ex.csv new file mode 100644 index 0000000000000000000000000000000000000000..33ba8689f4826e833b30e09b3f90c590fa0c2eb1 --- /dev/null +++ b/tests/fixtures/Results_ConJ11Ex.csv @@ -0,0 +1,4 @@ + ,Label,Area,Perim.,Circ.,AR,Round,Solidity +1,ConJ11ExB1.jpg,11.22,54.93,1.212,0.4,0.4,0.271 +2,ConJ11ExB2.jpg,33.4,33.93,0.365,1.625,0.615,0.871 +3,ConJ11ExB9.jpg,15.9,55.87,0.6,1.98,0.1,0.45 diff --git a/tests/fixtures/Results_ConJ11ExB1.csv b/tests/fixtures/Results_ConJ11ExB1.csv new file mode 100644 index 0000000000000000000000000000000000000000..c89804d59d19876f30ca568176e8072299858e78 --- /dev/null +++ b/tests/fixtures/Results_ConJ11ExB1.csv @@ -0,0 +1,2 @@ + ,Label,Area,Perim.,Circ.,AR,Round,Solidity +1,ConJ11ExB1.jpg,11.22,54.93,1.212,0.4,0.4,0.271 \ No newline at end of file diff --git a/tests/fixtures/Results_ConJ11ExB2.csv b/tests/fixtures/Results_ConJ11ExB2.csv new file mode 100644 index 0000000000000000000000000000000000000000..31b385d81b2cef124d6daad76f886a378e6334ea --- /dev/null +++ b/tests/fixtures/Results_ConJ11ExB2.csv @@ -0,0 +1,2 @@ + ,Label,Area,Perim.,Circ.,AR,Round,Solidity +1,ConJ11ExB2.jpg,33.4,33.93,0.365,1.625,0.615,0.871 \ No newline at end of file diff --git a/tests/fixtures/Results_ConJ11ExB9.csv b/tests/fixtures/Results_ConJ11ExB9.csv new file mode 100644 index 0000000000000000000000000000000000000000..6f75c18c2acf07d06b80ddb28cfd40dffcf18feb --- /dev/null +++ b/tests/fixtures/Results_ConJ11ExB9.csv @@ -0,0 +1,2 @@ + ,Label,Area,Perim.,Circ.,AR,Round,Solidity +1,ConJ11ExB9.jpg,15.9,55.87,0.6,1.98,0.1,0.45 \ No newline at end of file diff --git a/tests/fixtures/Results_ExpJ1Cr.csv b/tests/fixtures/Results_ExpJ1Cr.csv new file mode 100644 index 0000000000000000000000000000000000000000..6d2caa0fc90d8ea188ec25d5b634648028981f5e --- /dev/null +++ b/tests/fixtures/Results_ExpJ1Cr.csv @@ -0,0 +1,2 @@ + ,Label,Area,Perim.,Circ.,AR,Round,Solidity +1,ExpJ1CrB9.jpg,33.4,33.93,0.365,1.625,0.615,0.871 \ No newline at end of file diff --git a/tests/fixtures/fixtures.ts b/tests/fixtures/fixtures.ts index 4c85fd40c2c6cb2e1535f4fb4ee2ff32684673da..9b201a288c8be41ad0a301e985cbd183eb5d6d53 100644 --- a/tests/fixtures/fixtures.ts +++ b/tests/fixtures/fixtures.ts @@ -1,37 +1,45 @@ -/** - * Une classe utilitaire pour données mockées - */ -import expJ1CrB9CoordBlobTxt from './ExpJ1CrB9_Coord_Blob.txt'; -import {DataImporter} from "../../src/data/dataImporter"; -import {LabData} from "../../src/lab"; -import {VectorCoords} from "../../src/data/coords/vectorCoords"; -import {EllipseCoords} from "../../src/data/coords/ellipseCoords"; - -/** - * Charge des données de tes - */ -export class Fixtures { - - /** - * Pour charger les données brutes - */ - private static dataImporter = new DataImporter(); - - /** - * Contenu de ExpJ1CrB9_Coord_Blob.txt - */ - public static labData() : LabData { - return { - filename: "ExpJ1CrB9.jpg", - pictureSize: new paper.Size(2250, 4000), - rulerCoords: new VectorCoords( new paper.Point(542,438), new paper.Point(1929, 398)), - rulerTickCount : 10, - petriDishCoords: new EllipseCoords(new paper.Point(1172, 1305), 631, 625), - blobMaskCoords : this.dataImporter.import(expJ1CrB9CoordBlobTxt), - } - } - - - - +/** + * Une classe utilitaire pour données mockées + */ +import expJ1CrB9CoordBlobTxt from './ExpJ1CrB9_Coord_Blob.txt'; +import {DataImporter} from "../../src/data/dataImporter"; +import {LabData} from "../../src/lab"; +import {VectorCoords} from "../../src/data/coords/vectorCoords"; +import {EllipseCoords} from "../../src/data/coords/ellipseCoords"; +import {WorkInfoFormatter} from "../../src/data/work/process/workInfoFormatter"; +import {WorkInfoParser} from "../../src/data/work/process/workInfoParser"; + +/** + * Charge des données de tes + */ +export class Fixtures { + + /** + * Pour charger les données brutes + */ + private static dataImporter = new DataImporter(); + + /** + * Pour écrire les + */ + private static workInfoParser = new WorkInfoParser(); + + /** + * Contenu de ExpJ1CrB9_Coord_Blob.txt + */ + public static labData() : LabData { + return { + filename: "ExpJ1CrB9.jpg", + pictureSize: new paper.Size(2250, 4000), + workInfo: this.workInfoParser.parse("ExpJ1CrB9"), + rulerCoords: new VectorCoords( new paper.Point(542,438), new paper.Point(1929, 398)), + rulerTickCount : 10, + petriDishCoords: new EllipseCoords(new paper.Point(1172, 1305), 631, 625), + blobMaskCoords : this.dataImporter.readPathCoords(expJ1CrB9CoordBlobTxt), + } + } + + + + } \ No newline at end of file