Comparar commits
32 Commits
| Autor | SHA1 | Data | |
|---|---|---|---|
| a205a78858 | |||
| 745e854cc4 | |||
| 2234c8a0cc | |||
| 4d02db4215 | |||
| 90754dc74b | |||
| f29963c89e | |||
| acbbc86151 | |||
| 921dbac155 | |||
| a2ef52c7a4 | |||
| 25b4aa2dc8 | |||
| 0429590bec | |||
| 48805dcb85 | |||
| 0b06d6ac5b | |||
| 3998b6efc8 | |||
| 4a0dcb2905 | |||
| 354623ec21 | |||
| 8b34db5724 | |||
| bbd83a4df5 | |||
| 5b4610b3d9 | |||
| 996a555333 | |||
| 8439574dc5 | |||
| 4193bc0e6a | |||
| f394ea3d10 | |||
| 4f325dfe4b | |||
| 2ef4e1387f | |||
| 1001528a16 | |||
| 6974aef9d1 | |||
| 37234ec2e9 | |||
| d6a059447d | |||
| c10c971caf | |||
| 0fe63863b1 | |||
| 39521f2b61 |
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
+20
-2
@@ -2,8 +2,26 @@
|
||||
|
||||
<!-- cl-start -->
|
||||
|
||||
# [2.1.0](https://github.com/Microsoft/VoTT/compare/v2.0.0...v2.1.0) (04-29-2019)
|
||||
[GitHub Release](https://github.com/Microsoft/VoTT/releases/tag/v2.1.0)
|
||||
|
||||
- fix: Updates backwards compat & fixes cntk export image bug (#789)
|
||||
- fix: Updates export options for pascalVOC rename (#788)
|
||||
- fix: change method for alloc string to buffer (#777)
|
||||
- feat: Add CSV Exporter (#757)
|
||||
- fix: Fix display of tag color picker (#782)
|
||||
- feat: Active Learning Updates (#778)
|
||||
- doc: updates to readme and changelog (#781)
|
||||
- doc: Adds CODE_OF_CONDUCT.md (#779)
|
||||
- doc: Add bug & feature templates (#780)
|
||||
- fix: Refactored project tag/delete updates (#764)
|
||||
- fix: Enables selection of azure region for custom vision export (#765)
|
||||
- feat: CNTK Export Provider (#771)
|
||||
- feat: Save partial project progress during project creation (#769)
|
||||
- fix: Fixes ymax and rename Tensorflow nama everywhere (#763)
|
||||
|
||||
# [2.0.0](https://github.com/Microsoft/VoTT/compare/v2.0.0-preview.3...v2.0.0) (04-12-2019)
|
||||
[GitHub Release](https://github.com/Microsoft/VoTT/releases/tag/2.0.0)
|
||||
[GitHub Release](https://github.com/Microsoft/VoTT/releases/tag/v2.0.0)
|
||||
|
||||
- doc: update v1/master reference (#748)
|
||||
- ci: update pipeline for v2 flipover to master (#747)
|
||||
@@ -36,7 +54,7 @@
|
||||
- ci: update pipelines to work for all branches with prefix dev (#700)
|
||||
|
||||
# [2.0.0-preview.3](https://github.com/Microsoft/VoTT/compare/v2.0.0-preview.2...v2.0.0-preview.3) (03-20-2019)
|
||||
[GitHub Release](https://github.com/Microsoft/VoTT/releases/tag/2.0.0-preview.3)
|
||||
[GitHub Release](https://github.com/Microsoft/VoTT/releases/tag/v2.0.0-preview.3)
|
||||
|
||||
- ci: Clean up sonar cloud issues
|
||||
- Remove height from root style (#694)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at opensource@microsoft.com. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
+35
-25
@@ -28,34 +28,37 @@ VoTT helps facilitate an end-to-end machine learning pipeline:
|
||||
|
||||
<!-- toc -->
|
||||
|
||||
* [Getting Started](#getting-started)
|
||||
* [Download and install a release package for your platform (recommended)](#download-and-install-a-release-package-for-your-platform-recommended)
|
||||
* [Build and run from source](#build-and-run-from-source)
|
||||
* [V1 & V2](#v1--v2)
|
||||
* [Where is V1](#where-is-v1)
|
||||
* [V1 releases](#v1-releases)
|
||||
* [Can I use my V1 project in V2](#can-i-use-my-v1-project-in-v2)
|
||||
* [Using VoTT](#using-vott)
|
||||
* [Creating Connections](#creating-connections)
|
||||
* [Creating a New Project](#creating-a-new-project)
|
||||
* [Project Settings](#project-settings)
|
||||
* [Security Tokens](#security-tokens)
|
||||
* [Labeling an Image](#labeling-an-image)
|
||||
* [Labeling a Video](#labeling-a-video)
|
||||
* [Exporting Labels](#exporting-labels)
|
||||
* [Keyboard Shortcuts](#keyboard-shortcuts)
|
||||
* [Tag Shortcuts](#tag-shortcuts)
|
||||
* [Tag Ordering](#tag-ordering)
|
||||
* [Tag Locking](#tag-locking)
|
||||
* [Editor Shortcuts](#editor-shortcuts)
|
||||
* [Collaborators](#collaborators)
|
||||
* [Contributing to VoTT](#contributing-to-vott)
|
||||
- [VoTT (Visual Object Tagging Tool)](#vott-visual-object-tagging-tool)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Download and install a release package for your platform (recommended)](#download-and-install-a-release-package-for-your-platform-recommended)
|
||||
- [Build and run from source](#build-and-run-from-source)
|
||||
- [Run as Web Application](#run-as-web-application)
|
||||
- [V1 & V2](#v1--v2)
|
||||
- [Where is V1](#where-is-v1)
|
||||
- [V1 releases](#v1-releases)
|
||||
- [V1 projects in V2](#v1-projects-in-v2)
|
||||
- [Using VoTT](#using-vott)
|
||||
- [Creating Connections](#creating-connections)
|
||||
- [Creating a New Project](#creating-a-new-project)
|
||||
- [Project Settings](#project-settings)
|
||||
- [Security Tokens](#security-tokens)
|
||||
- [Labeling an Image](#labeling-an-image)
|
||||
- [Labeling a Video](#labeling-a-video)
|
||||
- [Exporting Labels](#exporting-labels)
|
||||
- [Keyboard Shortcuts](#keyboard-shortcuts)
|
||||
- [Tag Ordering](#tag-ordering)
|
||||
- [Tag Locking](#tag-locking)
|
||||
- [Editor Shortcuts](#editor-shortcuts)
|
||||
- [Mouse Controls](#mouse-controls)
|
||||
- [Collaborators](#collaborators)
|
||||
- [Contributing to VoTT](#contributing-to-vott)
|
||||
|
||||
<!-- tocstop -->
|
||||
|
||||
## Getting Started
|
||||
|
||||
VoTT can be installed as a native application or run from source.
|
||||
VoTT can be installed as a native application or run from source. VoTT is also available as a [stand-alone Web application](https://vott.z5.web.core.windows.net) and can be used in any modern Web browser.
|
||||
|
||||
### Download and install a release package for your platform (recommended)
|
||||
|
||||
@@ -71,11 +74,16 @@ VoTT requires [NodeJS (>= 10.x, Dubnium) and NPM](https://github.com/nodejs/Rele
|
||||
npm ci
|
||||
npm start
|
||||
```
|
||||
|
||||
> **IMPORTANT**
|
||||
>
|
||||
> When running locally with `npm`, both the electron and the browser versions of the application will start. One major difference is that the electron version can access the local file system.
|
||||
|
||||
### Run as Web Application
|
||||
|
||||
Using a modern Web browser, VoTT can be loaded from: [https://vott.z5.web.core.windows.net](https://vott.z5.web.core.windows.net)
|
||||
|
||||
As noted above, the Web version of VoTT *cannot* access the local file system; all assets must be imported/exported through a Cloud project.
|
||||
|
||||
## V1 & V2
|
||||
|
||||
VoTT V2 is a refactor and refresh of the original Electron-based application. As the usage and demand for VoTT grew, `V2` was started as an initiative to improve and make VoTT more extensible and maintainable. In addition, `V2` uses more modern development frameworks and patterns (React, Redux) and is authored in TypeScript.
|
||||
@@ -187,8 +195,10 @@ Tagging and drawing regions is not possible while the video is playing.
|
||||
Once assets have been labeled, they can be exported into a variety of formats:
|
||||
|
||||
* [Azure Custom Vision Service](https://azure.microsoft.com/en-us/services/cognitive-services/custom-vision-service/)
|
||||
* [Microsoft Cognitive Toolkit (CNTK)](https://github.com/Microsoft/CNTK)
|
||||
* TensorFlow (Pascal VOC and TFRecords)
|
||||
* VoTT (generic JSON schema)
|
||||
* Comma Separated Values (CSV)
|
||||
|
||||
In addition, users may choose to export
|
||||
|
||||
@@ -238,7 +248,7 @@ VOTT allows you to fine tune the bounding boxes using the arrow keys in a few di
|
||||
* Ctrl + Alt + Arrowkey - Shrink Region
|
||||
* Ctrl + Shift + Arrowkey - Expand Region
|
||||
|
||||
The slide viewer can be navigated from the keyboard as follows:
|
||||
The slide viewer can be navigated from the keyboard as follows:
|
||||
|
||||
* W or ArrowUp - Previous Asset
|
||||
* S or ArrowDown - Next Asset
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[{"name":"/m/01g317","id":1,"displayName":"person"},{"name":"/m/0199g","id":2,"displayName":"bicycle"},{"name":"/m/0k4j","id":3,"displayName":"car"},{"name":"/m/04_sv","id":4,"displayName":"motorcycle"},{"name":"/m/05czz6l","id":5,"displayName":"airplane"},{"name":"/m/01bjv","id":6,"displayName":"bus"},{"name":"/m/07jdr","id":7,"displayName":"train"},{"name":"/m/07r04","id":8,"displayName":"truck"},{"name":"/m/019jd","id":9,"displayName":"boat"},{"name":"/m/015qff","id":10,"displayName":"traffic light"},{"name":"/m/01pns0","id":11,"displayName":"fire hydrant"},{"name":"/m/02pv19","id":13,"displayName":"stop sign"},{"name":"/m/015qbp","id":14,"displayName":"parking meter"},{"name":"/m/0cvnqh","id":15,"displayName":"bench"},{"name":"/m/015p6","id":16,"displayName":"bird"},{"name":"/m/01yrx","id":17,"displayName":"cat"},{"name":"/m/0bt9lr","id":18,"displayName":"dog"},{"name":"/m/03k3r","id":19,"displayName":"horse"},{"name":"/m/07bgp","id":20,"displayName":"sheep"},{"name":"/m/01xq0k1","id":21,"displayName":"cow"},{"name":"/m/0bwd_0j","id":22,"displayName":"elephant"},{"name":"/m/01dws","id":23,"displayName":"bear"},{"name":"/m/0898b","id":24,"displayName":"zebra"},{"name":"/m/03bk1","id":25,"displayName":"giraffe"},{"name":"/m/01940j","id":27,"displayName":"backpack"},{"name":"/m/0hnnb","id":28,"displayName":"umbrella"},{"name":"/m/080hkjn","id":31,"displayName":"handbag"},{"name":"/m/01rkbr","id":32,"displayName":"tie"},{"name":"/m/01s55n","id":33,"displayName":"suitcase"},{"name":"/m/02wmf","id":34,"displayName":"frisbee"},{"name":"/m/071p9","id":35,"displayName":"skis"},{"name":"/m/06__v","id":36,"displayName":"snowboard"},{"name":"/m/018xm","id":37,"displayName":"sports ball"},{"name":"/m/02zt3","id":38,"displayName":"kite"},{"name":"/m/03g8mr","id":39,"displayName":"baseball bat"},{"name":"/m/03grzl","id":40,"displayName":"baseball glove"},{"name":"/m/06_fw","id":41,"displayName":"skateboard"},{"name":"/m/019w40","id":42,"displayName":"surfboard"},{"name":"/m/0dv9c","id":43,"displayName":"tennis racket"},{"name":"/m/04dr76w","id":44,"displayName":"bottle"},{"name":"/m/09tvcd","id":46,"displayName":"wine glass"},{"name":"/m/08gqpm","id":47,"displayName":"cup"},{"name":"/m/0dt3t","id":48,"displayName":"fork"},{"name":"/m/04ctx","id":49,"displayName":"knife"},{"name":"/m/0cmx8","id":50,"displayName":"spoon"},{"name":"/m/04kkgm","id":51,"displayName":"bowl"},{"name":"/m/09qck","id":52,"displayName":"banana"},{"name":"/m/014j1m","id":53,"displayName":"apple"},{"name":"/m/0l515","id":54,"displayName":"sandwich"},{"name":"/m/0cyhj_","id":55,"displayName":"orange"},{"name":"/m/0hkxq","id":56,"displayName":"broccoli"},{"name":"/m/0fj52s","id":57,"displayName":"carrot"},{"name":"/m/01b9xk","id":58,"displayName":"hot dog"},{"name":"/m/0663v","id":59,"displayName":"pizza"},{"name":"/m/0jy4k","id":60,"displayName":"donut"},{"name":"/m/0fszt","id":61,"displayName":"cake"},{"name":"/m/01mzpv","id":62,"displayName":"chair"},{"name":"/m/02crq1","id":63,"displayName":"couch"},{"name":"/m/03fp41","id":64,"displayName":"potted plant"},{"name":"/m/03ssj5","id":65,"displayName":"bed"},{"name":"/m/04bcr3","id":67,"displayName":"dining table"},{"name":"/m/09g1w","id":70,"displayName":"toilet"},{"name":"/m/07c52","id":72,"displayName":"tv"},{"name":"/m/01c648","id":73,"displayName":"laptop"},{"name":"/m/020lf","id":74,"displayName":"mouse"},{"name":"/m/0qjjc","id":75,"displayName":"remote"},{"name":"/m/01m2v","id":76,"displayName":"keyboard"},{"name":"/m/050k8","id":77,"displayName":"cell phone"},{"name":"/m/0fx9l","id":78,"displayName":"microwave"},{"name":"/m/029bxz","id":79,"displayName":"oven"},{"name":"/m/01k6s3","id":80,"displayName":"toaster"},{"name":"/m/0130jx","id":81,"displayName":"sink"},{"name":"/m/040b_t","id":82,"displayName":"refrigerator"},{"name":"/m/0bt_c3","id":84,"displayName":"book"},{"name":"/m/01x3z","id":85,"displayName":"clock"},{"name":"/m/02s195","id":86,"displayName":"vase"},{"name":"/m/01lsmm","id":87,"displayName":"scissors"},{"name":"/m/0kmg4","id":88,"displayName":"teddy bear"},{"name":"/m/03wvsk","id":89,"displayName":"hair drier"},{"name":"/m/012xff","id":90,"displayName":"toothbrush"}]
|
||||
Arquivo binário não exibido.
Arquivo binário não exibido.
Arquivo binário não exibido.
Arquivo binário não exibido.
Arquivo binário não exibido.
Diferenças do arquivo suprimidas por serem muito extensas
Carregar Diff
@@ -20,3 +20,5 @@ linux:
|
||||
- snap
|
||||
publish: null
|
||||
electronVersion: 3.0.13
|
||||
extraFiles:
|
||||
- "cocoSSDModel"
|
||||
|
||||
gerado
+144
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vott",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -1017,6 +1017,55 @@
|
||||
"loader-utils": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"@tensorflow/tfjs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-1.0.3.tgz",
|
||||
"integrity": "sha512-tF6GcjO2KBYlPPiS7o4X+D3oASXJcWAYaZA13GCYp5cXAui0ncHxpC85kmNQlp2HEVmcE82BJz/1uUtkNxxQpw==",
|
||||
"requires": {
|
||||
"@tensorflow/tfjs-converter": "1.0.3",
|
||||
"@tensorflow/tfjs-core": "1.0.3",
|
||||
"@tensorflow/tfjs-data": "1.0.3",
|
||||
"@tensorflow/tfjs-layers": "1.0.3"
|
||||
}
|
||||
},
|
||||
"@tensorflow/tfjs-converter": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-1.0.3.tgz",
|
||||
"integrity": "sha512-vrGvVrPekhTOwMGsomcpcjw0ZUep6xhI8DQQoFXHjBcprt9bFO2hHMdAmYpqafcJ7KVMylbK4h2LJrsBI2zDgQ=="
|
||||
},
|
||||
"@tensorflow/tfjs-core": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-1.0.3.tgz",
|
||||
"integrity": "sha512-2UbjMQkmrykIIZuoRfmDPrtWm+6fdQRlYLCUJdiOIooeu/q4nye587HM1qKcdZosGPZTW6VvX+4VIVieYn5i0A==",
|
||||
"requires": {
|
||||
"@types/seedrandom": "2.4.27",
|
||||
"@types/webgl-ext": "0.0.30",
|
||||
"@types/webgl2": "0.0.4",
|
||||
"seedrandom": "2.4.3"
|
||||
}
|
||||
},
|
||||
"@tensorflow/tfjs-data": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-data/-/tfjs-data-1.0.3.tgz",
|
||||
"integrity": "sha512-WFjYU2pWNZ0TZaJ7rN18GD/wOTVe6rBGxvSwZxIhEVIbwKXaKXFa9V4aGp4QBG9AXHIA89SjmGSGPxfsC015hQ==",
|
||||
"requires": {
|
||||
"@types/node-fetch": "^2.1.2",
|
||||
"node-fetch": "~2.1.2",
|
||||
"seedrandom": "~2.4.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-fetch": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz",
|
||||
"integrity": "sha1-q4hOjn5X44qUR1POxwb3iNF2i7U="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tensorflow/tfjs-layers": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-layers/-/tfjs-layers-1.0.3.tgz",
|
||||
"integrity": "sha512-7VdvQb0ft7TrWAbBy7HI+p420KX9rblYYACZ7/BzvzsikfEOdEL90WxrZDjZ167rYN/KvqC/haGcmhW/dYU3MA=="
|
||||
},
|
||||
"@types/axios": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.0.tgz",
|
||||
@@ -1074,6 +1123,15 @@
|
||||
"integrity": "sha512-NVQEMviDWjuen3UW+mU1J6fZ0WhOfG1yRce/2OTcbaz+fgmTw2cahx6N2wh0Yl+a+hg2UZj/oElZmtULWyGIsA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/json2csv": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/json2csv/-/json2csv-4.4.0.tgz",
|
||||
"integrity": "sha512-24S6hQGGsOZxTXbRyKvNaV5k882XTo9RX/LH6+RtVtimFNE2J0T/LWlru6BeEssByVA9/ZLif1PLk/8X8/qPCQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.120",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.120.tgz",
|
||||
@@ -1087,8 +1145,15 @@
|
||||
"@types/node": {
|
||||
"version": "10.12.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.7.tgz",
|
||||
"integrity": "sha512-Zh5Z4kACfbeE8aAOYh9mqotRxaZMro8MbBQtR8vEXOMiZo2rGEh2LayJijKdlu48YnS6y2EFU/oo2NCe5P6jGw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-Zh5Z4kACfbeE8aAOYh9mqotRxaZMro8MbBQtR8vEXOMiZo2rGEh2LayJijKdlu48YnS6y2EFU/oo2NCe5P6jGw=="
|
||||
},
|
||||
"@types/node-fetch": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.1.7.tgz",
|
||||
"integrity": "sha512-TZozHCDVrs0Aj1B9ZR5F4Q9MknDNcVd+hO5lxXOCzz07ELBey6s1gMUSZHUYHlPfRFKJFXiTnNuD7ePiI6S4/g==",
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/prop-types": {
|
||||
"version": "15.5.8",
|
||||
@@ -1230,6 +1295,11 @@
|
||||
"redux": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@types/seedrandom": {
|
||||
"version": "2.4.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz",
|
||||
"integrity": "sha1-nbVjk33YaRX2kJK8QyWdL0hXjkE="
|
||||
},
|
||||
"@types/snapsvg": {
|
||||
"version": "0.4.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/snapsvg/-/snapsvg-0.4.35.tgz",
|
||||
@@ -1243,6 +1313,16 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.2.tgz",
|
||||
"integrity": "sha512-42zEJkBpNfMEAvWR5WlwtTH22oDzcMjFsL9gDGExwF8X8WvAiw7Vwop7hPw03QT8TKfec83LwbHj6SvpqM4ELQ=="
|
||||
},
|
||||
"@types/webgl-ext": {
|
||||
"version": "0.0.30",
|
||||
"resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
|
||||
"integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg=="
|
||||
},
|
||||
"@types/webgl2": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/webgl2/-/webgl2-0.0.4.tgz",
|
||||
"integrity": "sha512-PACt1xdErJbMUOUweSrbVM7gSIYm1vTncW2hF6Os/EeWi6TXYAYMPp+8v6rzHmypE5gHrxaxZNXgMkJVIdZpHw=="
|
||||
},
|
||||
"@webassemblyjs/ast": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.7.6.tgz",
|
||||
@@ -7239,7 +7319,8 @@
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
|
||||
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
|
||||
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
|
||||
"optional": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
@@ -7282,7 +7363,8 @@
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
|
||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
|
||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
|
||||
"optional": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
@@ -7293,7 +7375,8 @@
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
|
||||
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
|
||||
"optional": true
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -7423,6 +7506,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
|
||||
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
@@ -7552,7 +7636,8 @@
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
|
||||
"optional": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
@@ -7650,7 +7735,8 @@
|
||||
"safe-buffer": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
|
||||
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="
|
||||
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
|
||||
"optional": true
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -7686,6 +7772,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
|
||||
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
@@ -7705,6 +7792,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
|
||||
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
}
|
||||
@@ -7748,12 +7836,14 @@
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"optional": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz",
|
||||
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k="
|
||||
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9372,6 +9462,17 @@
|
||||
"requires": {
|
||||
"node-fetch": "^1.0.1",
|
||||
"whatwg-fetch": ">=0.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-fetch": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
|
||||
"integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
|
||||
"requires": {
|
||||
"encoding": "^0.1.11",
|
||||
"is-stream": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isstream": {
|
||||
@@ -10142,6 +10243,11 @@
|
||||
"topo": "2.x.x"
|
||||
}
|
||||
},
|
||||
"jpeg-js": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.3.4.tgz",
|
||||
"integrity": "sha512-6IzjQxvnlT8UlklNmDXIJMWxijULjqGrzgqc0OG7YadZdvm7KPQ1j0ehmQQHckgEWOfgpptzcnWgESovxudpTA=="
|
||||
},
|
||||
"jquery": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz",
|
||||
@@ -10260,6 +10366,16 @@
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
|
||||
},
|
||||
"json2csv": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/json2csv/-/json2csv-4.5.0.tgz",
|
||||
"integrity": "sha512-SfWprYRawoBsEHZyb1NvZz2qJpuLRDlSexHqtnHxFEFvzK83zgm7Bftq2miBODjRQG0O7PaHC5271Hfbu10P+w==",
|
||||
"requires": {
|
||||
"commander": "^2.15.1",
|
||||
"jsonparse": "^1.3.1",
|
||||
"lodash.get": "^4.4.2"
|
||||
}
|
||||
},
|
||||
"json3": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz",
|
||||
@@ -10283,6 +10399,11 @@
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
|
||||
"integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM="
|
||||
},
|
||||
"jsonparse": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
|
||||
"integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA="
|
||||
},
|
||||
"jsprim": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
|
||||
@@ -10518,6 +10639,11 @@
|
||||
"integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.get": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
|
||||
},
|
||||
"lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
@@ -11253,13 +11379,9 @@
|
||||
}
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
|
||||
"integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
|
||||
"requires": {
|
||||
"encoding": "^0.1.11",
|
||||
"is-stream": "^1.0.1"
|
||||
}
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.3.0.tgz",
|
||||
"integrity": "sha512-MOd8pV3fxENbryESLgVIeaGKrdl+uaYhCSSVkjeOb/31/njTpcis5aWfdqgNlHIrKOLRbMnfPINPOML2CIFeXA=="
|
||||
},
|
||||
"node-forge": {
|
||||
"version": "0.7.5",
|
||||
@@ -16369,6 +16491,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"seedrandom": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz",
|
||||
"integrity": "sha1-JDhQTa0zkXMUv/GKxNeU8W1qrsw="
|
||||
},
|
||||
"select-hose": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vott",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"author": {
|
||||
"name": "Microsoft",
|
||||
"url": "https://github.com/Microsoft/VoTT"
|
||||
@@ -16,6 +16,7 @@
|
||||
"main": "build/main.js",
|
||||
"dependencies": {
|
||||
"@azure/storage-blob": "^10.3.0",
|
||||
"@tensorflow/tfjs": "^1.0.3",
|
||||
"@types/snapsvg": "^0.4.35",
|
||||
"axios": "^0.18.0",
|
||||
"bootstrap": "^4.1.3",
|
||||
@@ -23,8 +24,11 @@
|
||||
"crypto-js": "^3.1.9-1",
|
||||
"dotenv": "^7.0.0",
|
||||
"google-protobuf": "^3.6.1",
|
||||
"jpeg-js": "^0.3.4",
|
||||
"json2csv": "^4.5.0",
|
||||
"lodash": "^4.17.11",
|
||||
"md5.js": "^1.3.5",
|
||||
"node-fetch": "^2.3.0",
|
||||
"node-int64": "^0.4.0",
|
||||
"rc-align": "^2.4.5",
|
||||
"rc-checkbox": "^2.1.6",
|
||||
@@ -95,6 +99,7 @@
|
||||
"@types/dotenv": "^6.1.0",
|
||||
"@types/enzyme": "^3.1.15",
|
||||
"@types/jest": "23.3.9",
|
||||
"@types/json2csv": "^4.4.0",
|
||||
"@types/node": "10.12.7",
|
||||
"@types/react": "16.7.6",
|
||||
"@types/react-dom": "16.0.9",
|
||||
|
||||
@@ -10,17 +10,7 @@ describe("Html File Reader", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
assetTestCache.clear();
|
||||
|
||||
document.createElement = jest.fn((elementType) => {
|
||||
switch (elementType) {
|
||||
case "img":
|
||||
return mockImage();
|
||||
case "video":
|
||||
return mockVideo();
|
||||
case "canvas":
|
||||
return mockCanvas();
|
||||
}
|
||||
});
|
||||
MockFactory.mockElement(assetTestCache);
|
||||
});
|
||||
|
||||
it("Resolves promise after successfully reading file", async () => {
|
||||
@@ -234,67 +224,4 @@ describe("Html File Reader", () => {
|
||||
await expect(HtmlFileReader.getAssetFrameImage(videoErrorFrame)).rejects.not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const mockImage = jest.fn(() => {
|
||||
const element: any = {
|
||||
naturalWidth: 0,
|
||||
naturalHeight: 0,
|
||||
onload: jest.fn(),
|
||||
};
|
||||
|
||||
setImmediate(() => {
|
||||
const asset = assetTestCache.get(element.src);
|
||||
element.naturalWidth = asset.size.width;
|
||||
element.naturalHeight = asset.size.height;
|
||||
|
||||
element.onload();
|
||||
});
|
||||
|
||||
return element;
|
||||
});
|
||||
|
||||
const mockVideo = jest.fn(() => {
|
||||
const element: any = {
|
||||
src: "",
|
||||
duration: 0,
|
||||
currentTime: 0,
|
||||
videoWidth: 0,
|
||||
videoHeight: 0,
|
||||
onloadedmetadata: jest.fn(),
|
||||
onseeked: jest.fn(),
|
||||
onerror: jest.fn(),
|
||||
};
|
||||
|
||||
setImmediate(() => {
|
||||
const asset = assetTestCache.get(element.src);
|
||||
if (asset.name.toLowerCase().indexOf("error") > -1) {
|
||||
element.onerror("An error occurred loading the video");
|
||||
} else {
|
||||
element.videoWidth = asset.size.width;
|
||||
element.videoHeight = asset.size.height;
|
||||
element.currentTime = asset.timestamp;
|
||||
element.onloadedmetadata();
|
||||
element.onseeked();
|
||||
}
|
||||
});
|
||||
|
||||
return element;
|
||||
});
|
||||
|
||||
const mockCanvas = jest.fn(() => {
|
||||
const canvas: any = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: jest.fn(() => {
|
||||
return {
|
||||
drawImage: jest.fn(),
|
||||
};
|
||||
}),
|
||||
toBlob: jest.fn((callback) => {
|
||||
callback(new Blob(["Binary image data"]));
|
||||
}),
|
||||
};
|
||||
|
||||
return canvas;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,6 +143,7 @@ export const english: IAppStrings = {
|
||||
warnings: {
|
||||
existingName: "Tag name already exists. Choose another name",
|
||||
emptyName: "Cannot have an empty tag name",
|
||||
unknownTagName: "Unknown",
|
||||
},
|
||||
toolbar: {
|
||||
add: "Add new tag",
|
||||
@@ -178,6 +179,10 @@ export const english: IAppStrings = {
|
||||
title: "Account Name",
|
||||
description: "",
|
||||
},
|
||||
accountKey: {
|
||||
title: "Account Key",
|
||||
description: "",
|
||||
},
|
||||
containerName: {
|
||||
title: "Container Name",
|
||||
description: "",
|
||||
@@ -231,6 +236,7 @@ export const english: IAppStrings = {
|
||||
nextAsset: "Next Asset",
|
||||
saveProject: "Save Project",
|
||||
exportProject: "Export Project",
|
||||
activeLearning: "Active Learning",
|
||||
},
|
||||
videoPlayer: {
|
||||
previousTaggedFrame: {
|
||||
@@ -256,6 +262,15 @@ export const english: IAppStrings = {
|
||||
apply: "Apply Tag with Hot Key",
|
||||
lock: "Lock Tag with Hot Key",
|
||||
},
|
||||
rename: {
|
||||
title: "Rename Tag",
|
||||
confirmation: "Are you sure you want to rename this tag? It will be renamed throughout all assets",
|
||||
},
|
||||
delete: {
|
||||
title: "Delete Tag",
|
||||
confirmation: "Are you sure you want to delete this tag? It will be deleted throughout all assets \
|
||||
and any regions where this is the only tag will also be deleted",
|
||||
},
|
||||
},
|
||||
canvas: {
|
||||
removeAllRegions: {
|
||||
@@ -266,8 +281,8 @@ export const english: IAppStrings = {
|
||||
messages: {
|
||||
enforceTaggedRegions: {
|
||||
title: "Invalid region(s) detected",
|
||||
// tslint:disable-next-line:max-line-length
|
||||
description: "1 or more regions have not been tagged. Ensure all regions are tagged before continuing to next asset.",
|
||||
description: "1 or more regions have not been tagged. Ensure all regions are tagged before \
|
||||
continuing to next asset.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -287,23 +302,43 @@ export const english: IAppStrings = {
|
||||
tagged: "Only tagged Assets",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
vottJson: {
|
||||
displayName: "VoTT JSON",
|
||||
properties: {
|
||||
testTrainSplit: {
|
||||
title: "Test / Train Split",
|
||||
description: "The test train split to use for exported data",
|
||||
},
|
||||
includeImages: {
|
||||
title: "Include Images",
|
||||
description: "Whether or not to include binary image assets in target connection",
|
||||
},
|
||||
},
|
||||
},
|
||||
vottJson: {
|
||||
displayName: "VoTT JSON",
|
||||
},
|
||||
azureCV: {
|
||||
displayName: "Azure Custom Vision Service",
|
||||
regions: {
|
||||
australiaEast: "Australia East",
|
||||
centralIndia: "Central India",
|
||||
eastUs: "East US",
|
||||
eastUs2: "East US 2",
|
||||
japanEast: "Japan East",
|
||||
northCentralUs: "North Central US",
|
||||
northEurope: "North Europe",
|
||||
southCentralUs: "South Central US",
|
||||
southeastAsia: "Southeast Asia",
|
||||
ukSouth: "UK South",
|
||||
westUs2: "West US 2",
|
||||
westEurope: "West Europe",
|
||||
},
|
||||
properties: {
|
||||
apiKey: {
|
||||
title: "API Key",
|
||||
},
|
||||
region: {
|
||||
title: "Region",
|
||||
description: "The Azure region where your service is deployed",
|
||||
},
|
||||
classificationType: {
|
||||
title: "Classification Type",
|
||||
options: {
|
||||
@@ -342,17 +377,19 @@ export const english: IAppStrings = {
|
||||
tfRecords: {
|
||||
displayName: "Tensorflow Records",
|
||||
},
|
||||
tfPascalVoc: {
|
||||
displayName: "Tensorflow Pascal VOC",
|
||||
testTrainSplit: {
|
||||
title: "Test / Train Split",
|
||||
description: "The test train split to use for exported data",
|
||||
},
|
||||
pascalVoc: {
|
||||
displayName: "Pascal VOC",
|
||||
exportUnassigned: {
|
||||
title: "Export Unassigned",
|
||||
description: "Whether or not to include unassigned tags in exported data",
|
||||
},
|
||||
},
|
||||
cntk: {
|
||||
displayName: "Microsoft Cognitive Toolkit (CNTK)",
|
||||
},
|
||||
csv: {
|
||||
displayName: "Comma Separated Values (CSV)",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
saveSuccess: "Successfully saved export settings",
|
||||
@@ -360,6 +397,40 @@ export const english: IAppStrings = {
|
||||
},
|
||||
activeLearning: {
|
||||
title: "Active Learning",
|
||||
form: {
|
||||
properties: {
|
||||
modelPathType: {
|
||||
title: "Model Provider",
|
||||
description: "Where to load the training model from",
|
||||
options: {
|
||||
preTrained: "Pre-trained Coco SSD",
|
||||
customFilePath: "Custom (File path)",
|
||||
customWebUrl: "Custom (Url)",
|
||||
},
|
||||
},
|
||||
autoDetect: {
|
||||
title: "Auto Detect",
|
||||
description: "Whether or not to automatically make predictions as you navigate between assets",
|
||||
},
|
||||
modelPath: {
|
||||
title: "Model path",
|
||||
description: "Select a model from your local file system",
|
||||
},
|
||||
modelUrl: {
|
||||
title: "Model URL",
|
||||
description: "Load your model from a public web URL",
|
||||
},
|
||||
predictTag: {
|
||||
title: "Predict Tag",
|
||||
description: "Whether or not to automatically include tags in predictions",
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
loadingModel: "Loading active learning model...",
|
||||
errorLoadModel: "Error loading active learning model",
|
||||
saveSuccess: "Successfully saved active learning settings",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
settings: "Profile Settings",
|
||||
@@ -413,5 +484,10 @@ export const english: IAppStrings = {
|
||||
title: "Error exporting project",
|
||||
message: "Project is missing export format. Please select an export format in the export setting page.",
|
||||
},
|
||||
activeLearningPredictionError: {
|
||||
title: "Active Learning Error",
|
||||
message: "An error occurred while predicting regions in the current asset. \
|
||||
Please verify your active learning configuration and try again",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,8 +60,8 @@ export const spanish: IAppStrings = {
|
||||
},
|
||||
securityTokens: {
|
||||
title: "Tokens de seguridad",
|
||||
// tslint:disable-next-line:max-line-length
|
||||
description: "Los tokens de seguridad se utilizan para cifrar datos confidenciales dentro de la configuración del proyecto",
|
||||
description: "Los tokens de seguridad se utilizan para cifrar datos confidenciales \
|
||||
dentro de la configuración del proyecto",
|
||||
},
|
||||
version: {
|
||||
description: "Versión:",
|
||||
@@ -144,6 +144,7 @@ export const spanish: IAppStrings = {
|
||||
warnings: {
|
||||
existingName: "Nombre de etiqueta ya existe. Elige otro nombre",
|
||||
emptyName: "El nombre de etiqueta no puede ser vacío",
|
||||
unknownTagName: "Desconocido",
|
||||
},
|
||||
toolbar: {
|
||||
add: "Agregar nueva etiqueta",
|
||||
@@ -180,6 +181,10 @@ export const spanish: IAppStrings = {
|
||||
title: "Nombre de cuenta",
|
||||
description: "",
|
||||
},
|
||||
accountKey: {
|
||||
title: "Clave de cuenta",
|
||||
description: "",
|
||||
},
|
||||
containerName: {
|
||||
title: "Nombre del contenedor",
|
||||
description: "",
|
||||
@@ -233,6 +238,7 @@ export const spanish: IAppStrings = {
|
||||
nextAsset: "Siguiente activo",
|
||||
saveProject: "Guardar Proyecto",
|
||||
exportProject: "Exprtar Proyecto",
|
||||
activeLearning: "Aprendizaje Activo",
|
||||
},
|
||||
videoPlayer: {
|
||||
previousTaggedFrame: {
|
||||
@@ -258,6 +264,16 @@ export const spanish: IAppStrings = {
|
||||
apply: "Aplicar etiqueta con tecla de acceso rápido",
|
||||
lock: "Bloquear etiqueta con tecla de acceso rápido",
|
||||
},
|
||||
rename: {
|
||||
title: "Cambiar el nombre de la etiqueta",
|
||||
confirmation: "¿Está seguro que quiere cambiar el nombre de esta etiqueta? \
|
||||
Será cambiada en todos los activos",
|
||||
},
|
||||
delete: {
|
||||
title: "Delete Tag",
|
||||
confirmation: "¿Está seguro que quiere borrar esta etiqueta? Será borrada en todos \
|
||||
los activos y en las regiones donde esta etiqueta sea la única, la region también será borrada",
|
||||
},
|
||||
},
|
||||
canvas: {
|
||||
removeAllRegions: {
|
||||
@@ -268,8 +284,8 @@ export const spanish: IAppStrings = {
|
||||
messages: {
|
||||
enforceTaggedRegions: {
|
||||
title: "Las regiones no válidas detectadas",
|
||||
// tslint:disable-next-line:max-line-length
|
||||
description: "1 o más regiones no se han etiquetado. Por favor, etiquete todas las regiones antes de continuar con el siguiente activo.",
|
||||
description: "1 o más regiones no se han etiquetado. \
|
||||
Por favor, etiquete todas las regiones antes de continuar con el siguiente activo.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -289,23 +305,43 @@ export const spanish: IAppStrings = {
|
||||
tagged: "Solo activos etiquetados",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
vottJson: {
|
||||
displayName: "VoTT JSON",
|
||||
properties: {
|
||||
testTrainSplit: {
|
||||
title: "La división para entrenar y comprobar",
|
||||
description: "La división de datos para utilizar entre el entrenamiento y la comprobación",
|
||||
},
|
||||
includeImages: {
|
||||
title: "Incluir imágenes",
|
||||
description: "Si desea o no incluir activos de imagen binaria en la conexión de destino",
|
||||
},
|
||||
},
|
||||
},
|
||||
vottJson: {
|
||||
displayName: "VoTT JSON",
|
||||
},
|
||||
azureCV: {
|
||||
displayName: "Servicio de Visión Personalizada Azure",
|
||||
regions: {
|
||||
australiaEast: "Australia este",
|
||||
centralIndia: "Centro de la India",
|
||||
eastUs: "Este de EE.",
|
||||
eastUs2: "Este US 2",
|
||||
japanEast: "Japón este",
|
||||
northCentralUs: "Centro norte de EE.",
|
||||
northEurope: "Europa del norte",
|
||||
southCentralUs: "Centro sur de EE.",
|
||||
southeastAsia: "Sudeste asiático",
|
||||
ukSouth: "UK sur",
|
||||
westUs2: "West US 2",
|
||||
westEurope: "Europa occidental",
|
||||
},
|
||||
properties: {
|
||||
apiKey: {
|
||||
title: "Clave de API",
|
||||
},
|
||||
region: {
|
||||
title: "Región",
|
||||
description: "La región de Azure donde se implementa el servicio",
|
||||
},
|
||||
classificationType: {
|
||||
title: "Tipo de clasificación",
|
||||
options: {
|
||||
@@ -344,17 +380,19 @@ export const spanish: IAppStrings = {
|
||||
tfRecords: {
|
||||
displayName: "Registros de Tensorflow",
|
||||
},
|
||||
tfPascalVoc: {
|
||||
displayName: "Tensorflow Pascal VOC",
|
||||
testTrainSplit: {
|
||||
title: "Prueba/tren Split",
|
||||
description: "La división del tren de prueba que se utilizará para los datos exportados",
|
||||
},
|
||||
pascalVoc: {
|
||||
displayName: "Pascal VOC",
|
||||
exportUnassigned: {
|
||||
title: "Exportar sin asignar",
|
||||
description: "Si se incluyen o no etiquetas no asignadas en los datos exportados",
|
||||
},
|
||||
},
|
||||
cntk: {
|
||||
displayName: "Microsoft Cognitive Toolkit (CNTK)",
|
||||
},
|
||||
csv: {
|
||||
displayName: "Los valores separados por comas (CSV)",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
saveSuccess: "Configuración de exportación guardada correctamente",
|
||||
@@ -362,6 +400,41 @@ export const spanish: IAppStrings = {
|
||||
},
|
||||
activeLearning: {
|
||||
title: "Aprendizaje Activo",
|
||||
form: {
|
||||
properties: {
|
||||
modelPathType: {
|
||||
title: "Proveedor del modelo",
|
||||
description: "Fuente desde la cual cargar el modelo",
|
||||
options: {
|
||||
preTrained: "SSD de coco pre-entrenado",
|
||||
customFilePath: "Personalizado (ruta de archivo)",
|
||||
customWebUrl: "Personalizado (URL)",
|
||||
},
|
||||
},
|
||||
autoDetect: {
|
||||
title: "Detección automática",
|
||||
description: "Si desea o no realizar automáticamente predicciones a \
|
||||
medida que navega entre activos",
|
||||
},
|
||||
modelPath: {
|
||||
title: "Ruta de modelo",
|
||||
description: "Seleccione un modelo de su sistema de archivos local",
|
||||
},
|
||||
modelUrl: {
|
||||
title: "URL del modelo",
|
||||
description: "Cargue el modelo desde una URL web pública",
|
||||
},
|
||||
predictTag: {
|
||||
title: "Predecir etiqueta",
|
||||
description: "Si se incluirán o no automáticamente las etiquetas en las predicciones",
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
loadingModel: "Cargando modelo...",
|
||||
errorLoadModel: "Error al cargar el modelo",
|
||||
saveSuccess: "La configuración de aprendizaje activa se ha guardada correctamente",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
settings: "Configuración de Perfíl",
|
||||
@@ -417,5 +490,10 @@ export const spanish: IAppStrings = {
|
||||
message: `Proyecto falta el formato de exportación. Seleccione un formato de exportación en la página
|
||||
de configuración de exportación.`,
|
||||
},
|
||||
activeLearningPredictionError: {
|
||||
title: "Error de aprendizaje",
|
||||
message: "Se ha producido un error al predecir regiones en el activo actual. \
|
||||
Compruebe la configuración de aprendizaje activa y vuelva a intentarlo",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+123
-12
@@ -3,7 +3,7 @@ import {
|
||||
AssetState, AssetType, IApplicationState, IAppSettings, IAsset, IAssetMetadata,
|
||||
IConnection, IExportFormat, IProject, ITag, StorageType, ISecurityToken,
|
||||
EditorMode, IAppError, IProjectVideoSettings, ErrorCode,
|
||||
IPoint, IRegion, RegionType,
|
||||
IPoint, IRegion, RegionType, ModelPathType,
|
||||
} from "../models/applicationState";
|
||||
import { IV1Project, IV1Region } from "../models/v1Models";
|
||||
import { ExportAssetState } from "../providers/export/exportProvider";
|
||||
@@ -33,6 +33,7 @@ import { SelectionMode } from "vott-ct/lib/js/CanvasTools/Interface/ISelectorSet
|
||||
import { IKeyboardBindingProps } from "../react/components/common/keyboardBinding/keyboardBinding";
|
||||
import { KeyEventType } from "../react/components/common/keyboardManager/keyboardManager";
|
||||
import { IKeyboardRegistrations } from "../react/components/common/keyboardManager/keyboardRegistrationManager";
|
||||
import { IActiveLearningPageProps } from "../react/components/pages/activeLearning/activeLearningPage";
|
||||
|
||||
export default class MockFactory {
|
||||
|
||||
@@ -283,6 +284,13 @@ export default class MockFactory {
|
||||
targetConnection: connection,
|
||||
tags: MockFactory.createTestTags(tagCount),
|
||||
videoSettings: MockFactory.createVideoSettings(),
|
||||
activeLearningSettings: {
|
||||
modelPathType: ModelPathType.Coco,
|
||||
modelPath: "",
|
||||
modelUrl: "",
|
||||
autoDetect: false,
|
||||
predictTag: false,
|
||||
},
|
||||
autoSave: true,
|
||||
};
|
||||
}
|
||||
@@ -630,14 +638,14 @@ export default class MockFactory {
|
||||
|
||||
/**
|
||||
* Creates array of IExportProviderRegistrationOptions for the different providers
|
||||
* vottJson, tensorFlowPascalVOC, azureCustomVision
|
||||
* vottJson, PascalVOC, azureCustomVision, csv
|
||||
*/
|
||||
public static createExportProviderRegistrations(): IExportProviderRegistrationOptions[] {
|
||||
const registrations: IExportProviderRegistrationOptions[] = [];
|
||||
registrations.push(MockFactory.createExportProviderRegistration("vottJson"));
|
||||
registrations.push(MockFactory.createExportProviderRegistration("tensorFlowPascalVOC"));
|
||||
registrations.push(MockFactory.createExportProviderRegistration("pascalVOC"));
|
||||
registrations.push(MockFactory.createExportProviderRegistration("azureCustomVision"));
|
||||
|
||||
registrations.push(MockFactory.createExportProviderRegistration("csv"));
|
||||
return registrations;
|
||||
}
|
||||
|
||||
@@ -810,14 +818,16 @@ export default class MockFactory {
|
||||
*/
|
||||
public static projectActions(): IProjectActions {
|
||||
return {
|
||||
loadProject: jest.fn((project: IProject) => Promise.resolve()),
|
||||
saveProject: jest.fn((project: IProject) => Promise.resolve()),
|
||||
deleteProject: jest.fn((project: IProject) => Promise.resolve()),
|
||||
loadProject: jest.fn(() => Promise.resolve()),
|
||||
saveProject: jest.fn(() => Promise.resolve()),
|
||||
deleteProject: jest.fn(() => Promise.resolve()),
|
||||
closeProject: jest.fn(() => Promise.resolve()),
|
||||
loadAssets: jest.fn((project: IProject) => Promise.resolve()),
|
||||
exportProject: jest.fn((project: IProject) => Promise.resolve()),
|
||||
loadAssetMetadata: jest.fn((project: IProject, asset: IAsset) => Promise.resolve()),
|
||||
saveAssetMetadata: jest.fn((project: IProject, assetMetadata: IAssetMetadata) => Promise.resolve()),
|
||||
loadAssets: jest.fn(() => Promise.resolve()),
|
||||
exportProject: jest.fn(() => Promise.resolve()),
|
||||
loadAssetMetadata: jest.fn(() => Promise.resolve()),
|
||||
saveAssetMetadata: jest.fn(() => Promise.resolve()),
|
||||
updateProjectTag: jest.fn(() => Promise.resolve()),
|
||||
deleteProjectTag: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -884,6 +894,21 @@ export default class MockFactory {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates fake IActiveLearningPageProps
|
||||
* @param projectId Current project ID
|
||||
*/
|
||||
public static activeLearningProps(projectId?: string): IActiveLearningPageProps {
|
||||
return {
|
||||
actions: (projectActions as any) as IProjectActions,
|
||||
history: MockFactory.history(),
|
||||
location: MockFactory.location(),
|
||||
match: MockFactory.match(projectId, "active-learning"),
|
||||
project: null,
|
||||
recentProjects: MockFactory.createTestProjects(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates fake IEditorPageProps
|
||||
* @param projectId Current project ID
|
||||
@@ -1010,6 +1035,93 @@ export default class MockFactory {
|
||||
};
|
||||
}
|
||||
|
||||
public static mockElement(assetTestCache: Map<string, IAsset>) {
|
||||
document.createElement = jest.fn((elementType) => {
|
||||
switch (elementType) {
|
||||
case "img":
|
||||
const mockImage = MockFactory.mockImage(assetTestCache);
|
||||
return mockImage();
|
||||
case "video":
|
||||
const mockVideo = MockFactory.mockVideo(assetTestCache);
|
||||
return mockVideo();
|
||||
case "canvas":
|
||||
const mockCanvas = MockFactory.mockCanvas();
|
||||
return mockCanvas();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static mockImage(assetTestCache: Map<string, IAsset>) {
|
||||
return jest.fn(() => {
|
||||
const element: any = {
|
||||
naturalWidth: 0,
|
||||
naturalHeight: 0,
|
||||
onload: jest.fn(),
|
||||
};
|
||||
|
||||
setImmediate(() => {
|
||||
const asset = assetTestCache.get(element.src);
|
||||
if (asset) {
|
||||
element.naturalWidth = asset.size.width;
|
||||
element.naturalHeight = asset.size.height;
|
||||
}
|
||||
|
||||
element.onload();
|
||||
});
|
||||
|
||||
return element;
|
||||
});
|
||||
}
|
||||
|
||||
public static mockVideo(assetTestCache: Map<string, IAsset>) {
|
||||
return jest.fn(() => {
|
||||
const element: any = {
|
||||
src: "",
|
||||
duration: 0,
|
||||
currentTime: 0,
|
||||
videoWidth: 0,
|
||||
videoHeight: 0,
|
||||
onloadedmetadata: jest.fn(),
|
||||
onseeked: jest.fn(),
|
||||
onerror: jest.fn(),
|
||||
};
|
||||
|
||||
setImmediate(() => {
|
||||
const asset = assetTestCache.get(element.src);
|
||||
if (asset.name.toLowerCase().indexOf("error") > -1) {
|
||||
element.onerror("An error occurred loading the video");
|
||||
} else {
|
||||
element.videoWidth = asset.size.width;
|
||||
element.videoHeight = asset.size.height;
|
||||
element.currentTime = asset.timestamp;
|
||||
element.onloadedmetadata();
|
||||
element.onseeked();
|
||||
}
|
||||
});
|
||||
|
||||
return element;
|
||||
});
|
||||
}
|
||||
|
||||
public static mockCanvas() {
|
||||
return jest.fn(() => {
|
||||
const canvas: any = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
getContext: jest.fn(() => {
|
||||
return {
|
||||
drawImage: jest.fn(),
|
||||
};
|
||||
}),
|
||||
toBlob: jest.fn((callback) => {
|
||||
callback(new Blob(["Binary image data"]));
|
||||
}),
|
||||
};
|
||||
|
||||
return canvas;
|
||||
});
|
||||
}
|
||||
|
||||
private static pageProps(projectId: string, method: string) {
|
||||
return {
|
||||
project: null,
|
||||
@@ -1091,5 +1203,4 @@ export default class MockFactory {
|
||||
return StorageType.Other;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+81
-10
@@ -154,6 +154,7 @@ export interface IAppStrings {
|
||||
warnings: {
|
||||
existingName: string;
|
||||
emptyName: string;
|
||||
unknownTagName: string;
|
||||
}
|
||||
};
|
||||
connections: {
|
||||
@@ -177,6 +178,10 @@ export interface IAppStrings {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
accountKey: {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
containerName: {
|
||||
title: string,
|
||||
description: string,
|
||||
@@ -230,6 +235,7 @@ export interface IAppStrings {
|
||||
nextAsset: string;
|
||||
saveProject: string;
|
||||
exportProject: string;
|
||||
activeLearning: string;
|
||||
}
|
||||
videoPlayer: {
|
||||
nextTaggedFrame: {
|
||||
@@ -255,6 +261,14 @@ export interface IAppStrings {
|
||||
apply: string;
|
||||
lock: string;
|
||||
},
|
||||
rename: {
|
||||
title: string;
|
||||
confirmation: string;
|
||||
},
|
||||
delete: {
|
||||
title: string;
|
||||
confirmation: string;
|
||||
},
|
||||
}
|
||||
canvas: {
|
||||
removeAllRegions: {
|
||||
@@ -285,23 +299,43 @@ export interface IAppStrings {
|
||||
tagged: string,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
vottJson: {
|
||||
displayName: string,
|
||||
properties: {
|
||||
testTrainSplit: {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
includeImages: {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
},
|
||||
},
|
||||
vottJson: {
|
||||
displayName: string,
|
||||
},
|
||||
azureCV: {
|
||||
displayName: string,
|
||||
regions: {
|
||||
eastUs: string,
|
||||
eastUs2: string,
|
||||
northCentralUs: string,
|
||||
southCentralUs: string,
|
||||
westUs2: string,
|
||||
westEurope: string,
|
||||
northEurope: string,
|
||||
southeastAsia: string,
|
||||
australiaEast: string,
|
||||
centralIndia: string,
|
||||
ukSouth: string,
|
||||
japanEast: string,
|
||||
},
|
||||
properties: {
|
||||
apiKey: {
|
||||
title: string,
|
||||
},
|
||||
region: {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
newOrExisting: {
|
||||
title: string,
|
||||
options: {
|
||||
@@ -340,17 +374,19 @@ export interface IAppStrings {
|
||||
tfRecords: {
|
||||
displayName: string,
|
||||
},
|
||||
tfPascalVoc: {
|
||||
pascalVoc: {
|
||||
displayName: string,
|
||||
testTrainSplit: {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
exportUnassigned: {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
},
|
||||
cntk: {
|
||||
displayName: string,
|
||||
},
|
||||
csv: {
|
||||
displayName: string,
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
saveSuccess: string;
|
||||
@@ -358,6 +394,40 @@ export interface IAppStrings {
|
||||
};
|
||||
activeLearning: {
|
||||
title: string;
|
||||
form: {
|
||||
properties: {
|
||||
modelPathType: {
|
||||
title: string,
|
||||
description: string,
|
||||
options: {
|
||||
preTrained: string,
|
||||
customFilePath: string,
|
||||
customWebUrl: string,
|
||||
},
|
||||
},
|
||||
autoDetect: {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
predictTag: {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
modelPath: {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
modelUrl: {
|
||||
title: string,
|
||||
description: string,
|
||||
},
|
||||
},
|
||||
}
|
||||
messages: {
|
||||
loadingModel: string;
|
||||
errorLoadModel: string;
|
||||
saveSuccess: string;
|
||||
}
|
||||
};
|
||||
profile: {
|
||||
settings: string;
|
||||
@@ -374,6 +444,7 @@ export interface IAppStrings {
|
||||
importError: IErrorMetadata,
|
||||
pasteRegionTooBigError: IErrorMetadata,
|
||||
exportFormatNotFound: IErrorMetadata,
|
||||
activeLearningPredictionError: IErrorMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("LocalFileSystem Storage Provider", () => {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
d: "한글 中國 にほんご",
|
||||
};
|
||||
|
||||
await localFileSystem.writeText(filePath, JSON.stringify(contents, null, 4));
|
||||
|
||||
@@ -71,7 +71,7 @@ export default class LocalFileSystem implements IStorageProvider {
|
||||
}
|
||||
|
||||
public writeText(filePath: string, contents: string): Promise<void> {
|
||||
const buffer = Buffer.alloc(contents.length, contents);
|
||||
const buffer = Buffer.from(contents);
|
||||
return this.writeBinary(filePath, buffer);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ExportAssetState } from "../providers/export/exportProvider";
|
||||
import { IAssetPreviewSettings } from "../react/components/common/assetPreview/assetPreview";
|
||||
|
||||
/**
|
||||
* @name - Application State
|
||||
@@ -49,6 +50,7 @@ export enum ErrorCode {
|
||||
ExportFormatNotFound = "exportFormatNotFound",
|
||||
PasteRegionTooBig = "pasteRegionTooBig",
|
||||
OverloadedKeyBinding = "overloadedKeyBinding",
|
||||
ActiveLearningPredictionError = "activeLearningPredictionError",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +114,7 @@ export interface IProject {
|
||||
targetConnection: IConnection;
|
||||
exportFormat: IExportFormat;
|
||||
videoSettings: IProjectVideoSettings;
|
||||
activeLearningSettings: IActiveLearningSettings;
|
||||
autoSave: boolean;
|
||||
assets?: { [index: string]: IAsset };
|
||||
lastVisitedAssetId?: string;
|
||||
@@ -198,6 +201,44 @@ export interface IProjectVideoSettings {
|
||||
frameExtractionRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name - Model Path Type
|
||||
* @description - Defines the mechanism to load the TF.js model for Active Learning
|
||||
* @member Coco - Specifies the default/generic pre-trained Coco-SSD model
|
||||
* @member File - Specifies to load a custom model from filesystem
|
||||
* @member Url - Specifies to load a custom model from a web server
|
||||
*/
|
||||
export enum ModelPathType {
|
||||
Coco = "coco",
|
||||
File = "file",
|
||||
Url = "url",
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties for additional project settings
|
||||
* @member activeLearningSettings - Active Learning settings
|
||||
*/
|
||||
export interface IAdditionalPageSettings extends IAssetPreviewSettings {
|
||||
activeLearningSettings: IActiveLearningSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name - Active Learning Settings for the project
|
||||
* @description - Defines the active learning settings within a VoTT project
|
||||
* @member modelPathType - Model loading type ["coco", "file", "url"]
|
||||
* @member modelPath - Local filesystem path to the TF.js model
|
||||
* @member modelUrl - Web url to the TF.js model
|
||||
* @member autoDetect - Flag for automatically call the model while opening a new asset
|
||||
* @member predictTag - Flag to predict also the tag name other than the rectangle coordinates only
|
||||
*/
|
||||
export interface IActiveLearningSettings {
|
||||
modelPathType: ModelPathType;
|
||||
modelPath?: string;
|
||||
modelUrl?: string;
|
||||
autoDetect: boolean;
|
||||
predictTag: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name - Asset Video Settings
|
||||
* @description - Defines the settings for video assets
|
||||
@@ -231,6 +272,7 @@ export interface IAsset {
|
||||
format?: string;
|
||||
timestamp?: number;
|
||||
parent?: IAsset;
|
||||
predicted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
jest.mock("../storage/localFileSystemProxy");
|
||||
import { LocalFileSystemProxy } from "../storage/localFileSystemProxy";
|
||||
import { ElectronProxyHandler } from "./electronProxyHandler";
|
||||
import * as tf from "@tensorflow/tfjs";
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const modelJson = require("../../../cocoSSDModel/model.json");
|
||||
|
||||
describe("Load default model from filesystem with TF io.IOHandler", () => {
|
||||
it("Check file system proxy is correctly called", async () => {
|
||||
const storageProviderMock = LocalFileSystemProxy as jest.Mock<LocalFileSystemProxy>;
|
||||
storageProviderMock.mockClear();
|
||||
|
||||
storageProviderMock.prototype.readText = jest.fn((fileName) => {
|
||||
return Promise.resolve(JSON.stringify(modelJson));
|
||||
});
|
||||
|
||||
storageProviderMock.prototype.readBinary = jest.fn((fileName) => {
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const handler = new ElectronProxyHandler("folder");
|
||||
try {
|
||||
const model = await tf.loadGraphModel(handler);
|
||||
} catch (_) {
|
||||
// fully loading TF model fails as it has to load also weights
|
||||
}
|
||||
|
||||
expect(LocalFileSystemProxy.prototype.readText).toBeCalledWith("/model.json");
|
||||
|
||||
// Coco SSD Lite default embedded model has 5 weights matrix
|
||||
expect(LocalFileSystemProxy.prototype.readBinary).toBeCalledTimes(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as tfc from "@tensorflow/tfjs-core";
|
||||
import { LocalFileSystemProxy, ILocalFileSystemProxyOptions } from "../../providers/storage/localFileSystemProxy";
|
||||
|
||||
export class ElectronProxyHandler implements tfc.io.IOHandler {
|
||||
protected readonly provider: LocalFileSystemProxy;
|
||||
|
||||
constructor(folderPath: string) {
|
||||
const options: ILocalFileSystemProxyOptions = { folderPath };
|
||||
this.provider = new LocalFileSystemProxy(options);
|
||||
}
|
||||
|
||||
public async load(): Promise<tfc.io.ModelArtifacts> {
|
||||
const modelJSON = JSON.parse(await this.provider.readText("/model.json"));
|
||||
|
||||
const modelArtifacts: tfc.io.ModelArtifacts = {
|
||||
modelTopology: modelJSON.modelTopology,
|
||||
};
|
||||
|
||||
if (modelJSON.weightsManifest != null) {
|
||||
const [weightSpecs, weightData] =
|
||||
await this.loadWeights(modelJSON.weightsManifest);
|
||||
modelArtifacts.weightSpecs = weightSpecs;
|
||||
modelArtifacts.weightData = weightData;
|
||||
}
|
||||
|
||||
return modelArtifacts;
|
||||
}
|
||||
|
||||
public async loadClasses(): Promise<JSON> {
|
||||
const json = await this.provider.readText("/classes.json");
|
||||
return json ? JSON.parse(json) : null;
|
||||
}
|
||||
|
||||
private async loadWeights(weightsManifest: tfc.io.WeightsManifestConfig)
|
||||
: Promise<[tfc.io.WeightsManifestEntry[], ArrayBuffer]> {
|
||||
const buffers: Buffer[] = [];
|
||||
const weightSpecs: tfc.io.WeightsManifestEntry[] = [];
|
||||
|
||||
for (const group of weightsManifest) {
|
||||
for (const shardName of group.paths) {
|
||||
const buffer = await this.provider.readBinary("/" + shardName);
|
||||
buffers.push(buffer);
|
||||
}
|
||||
weightSpecs.push(...group.weights);
|
||||
}
|
||||
|
||||
return [weightSpecs, this.toArrayBuffer(buffers)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Buffer or an Array of Buffers to an ArrayBuffer.
|
||||
*
|
||||
* If the input is an Array of Buffers, they will be concatenated in the
|
||||
* specified order to form the output ArrayBuffer.
|
||||
*/
|
||||
private toArrayBuffer(buf: Buffer | Buffer[]): ArrayBuffer {
|
||||
if (Array.isArray(buf)) {
|
||||
// An Array of Buffers.
|
||||
let totalLength = 0;
|
||||
for (const buffer of buf) {
|
||||
totalLength += buffer.length;
|
||||
}
|
||||
|
||||
const ab = new ArrayBuffer(totalLength);
|
||||
const view = new Uint8Array(ab);
|
||||
let pos = 0;
|
||||
for (const buffer of buf) {
|
||||
pos += buffer.copy(view, pos);
|
||||
}
|
||||
return ab;
|
||||
} else {
|
||||
// A single Buffer. Return a copy of the underlying ArrayBuffer slice.
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as tf from "@tensorflow/tfjs";
|
||||
jest.mock("../storage/localFileSystemProxy");
|
||||
import { LocalFileSystemProxy } from "../storage/localFileSystemProxy";
|
||||
import { ObjectDetection, DetectedObject } from "./objectDetection";
|
||||
import { strings } from "../../common/strings";
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const modelJson = require("../../../cocoSSDModel/model.json");
|
||||
|
||||
describe("Load an Object Detection model", () => {
|
||||
it("Load model from file system using proxy", async () => {
|
||||
const storageProviderMock = LocalFileSystemProxy as jest.Mock<LocalFileSystemProxy>;
|
||||
storageProviderMock.mockClear();
|
||||
|
||||
storageProviderMock.prototype.readText = jest.fn((fileName) => {
|
||||
return Promise.resolve(JSON.stringify(modelJson));
|
||||
});
|
||||
|
||||
storageProviderMock.prototype.readBinary = jest.fn((fileName) => {
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const model = new ObjectDetection();
|
||||
|
||||
try {
|
||||
await model.load("path");
|
||||
} catch (_) {
|
||||
// fully loading TF model fails has it has to load also weights
|
||||
}
|
||||
|
||||
expect(LocalFileSystemProxy.prototype.readText).toBeCalledWith("/model.json");
|
||||
|
||||
// Coco SSD Lite default embedded model has 5 weights matrix
|
||||
expect(LocalFileSystemProxy.prototype.readBinary).toBeCalledTimes(5);
|
||||
|
||||
// Modal not properly loaded as readBinary mock is not really loading the weights
|
||||
expect(model.loaded).toBeFalsy();
|
||||
|
||||
const noDetection = await model.detect(null);
|
||||
expect(noDetection.length).toEqual(0);
|
||||
|
||||
model.dispose();
|
||||
});
|
||||
|
||||
it("Load model from http url", async () => {
|
||||
window.fetch = jest.fn().mockImplementation((url, o) => {
|
||||
if (url === "http://url/model.json") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => modelJson,
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
data: () => [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const model = new ObjectDetection();
|
||||
|
||||
expect(model.load("http://url")).rejects.not.toBeNull();
|
||||
expect(window.fetch).toBeCalledTimes(1);
|
||||
|
||||
// Modal not properly loaded as readBinary mock is not really loading the weights
|
||||
expect(model.loaded).toBeFalsy();
|
||||
|
||||
const noDetection = await model.detect(null);
|
||||
expect(noDetection.length).toEqual(0);
|
||||
|
||||
model.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Detection on Fake Model", () => {
|
||||
beforeEach(() => {
|
||||
spyOn(tf, "loadGraphModel").and.callFake(() => {
|
||||
const model = {
|
||||
executeAsync:
|
||||
(x: tf.Tensor) => [tf.ones([1, 1917, 90]), tf.ones([1, 1917, 1, 4])],
|
||||
};
|
||||
|
||||
return model;
|
||||
});
|
||||
});
|
||||
|
||||
it("ObjectDetection detect method should generate output", async () => {
|
||||
const model = new ObjectDetection();
|
||||
await model.load("path");
|
||||
|
||||
const x = tf.zeros([227, 227, 3]) as tf.Tensor3D;
|
||||
|
||||
const data = await model.detect(x, 1);
|
||||
|
||||
expect(data).toEqual([{bbox: [227, 227, 0, 0], class: strings.tags.warnings.unknownTagName, score: 1}]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test predictImage on Fake Model", () => {
|
||||
beforeEach(() => {
|
||||
spyOn(tf, "loadGraphModel").and.callFake(() => {
|
||||
const model = {
|
||||
executeAsync:
|
||||
(x: tf.Tensor) => [tf.ones([1, 1917, 90]), tf.ones([1, 1917, 1, 4])],
|
||||
};
|
||||
|
||||
return model;
|
||||
});
|
||||
});
|
||||
|
||||
it("predictImage on a fake image", async () => {
|
||||
const model = new ObjectDetection();
|
||||
await model.load("path");
|
||||
|
||||
const x = tf.zeros([227, 227, 3]) as tf.Tensor3D;
|
||||
const regions = await model.predictImage(x, false, 1, 1);
|
||||
|
||||
expect(regions.length).toEqual(20);
|
||||
expect(regions[0].boundingBox.left).toEqual(227);
|
||||
expect(regions[0].boundingBox.top).toEqual(227);
|
||||
expect(regions[0].boundingBox.width).toEqual(0);
|
||||
expect(regions[0].boundingBox.height).toEqual(0);
|
||||
});
|
||||
});
|
||||
Arquivo executável
+253
@@ -0,0 +1,253 @@
|
||||
import axios from "axios";
|
||||
import * as shortid from "shortid";
|
||||
import * as tf from "@tensorflow/tfjs";
|
||||
import { ElectronProxyHandler } from "./electronProxyHandler";
|
||||
import { IRegion, RegionType } from "../../models/applicationState";
|
||||
import { strings } from "../../common/strings";
|
||||
|
||||
// tslint:disable-next-line:interface-over-type-literal
|
||||
export type DetectedObject = {
|
||||
bbox: [number, number, number, number]; // [x, y, width, height]
|
||||
class: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines supported data types supported by Tensorflow JS
|
||||
*/
|
||||
export type ImageObject = tf.Tensor3D | ImageData | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement;
|
||||
|
||||
/**
|
||||
* Object Dectection loads active learning models and predicts regions
|
||||
*/
|
||||
export class ObjectDetection {
|
||||
private modelLoaded: boolean = false;
|
||||
|
||||
get loaded(): boolean {
|
||||
return this.modelLoaded;
|
||||
}
|
||||
|
||||
private model: tf.GraphModel;
|
||||
private jsonClasses: JSON;
|
||||
|
||||
/**
|
||||
* Dispose the tensors allocated by the model. You should call this when you
|
||||
* are done with the model.
|
||||
*/
|
||||
public dispose() {
|
||||
if (this.model) {
|
||||
this.model.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a TensorFlow.js Object Detection model from file: or http URL.
|
||||
* @param modelFolderPath file: or http URL to the model
|
||||
*/
|
||||
public async load(modelFolderPath: string) {
|
||||
try {
|
||||
if (modelFolderPath.toLowerCase().startsWith("http://") ||
|
||||
modelFolderPath.toLowerCase().startsWith("https://")) {
|
||||
this.model = await tf.loadGraphModel(modelFolderPath + "/model.json");
|
||||
|
||||
const response = await axios.get(modelFolderPath + "/classes.json");
|
||||
this.jsonClasses = JSON.parse(JSON.stringify(response.data));
|
||||
} else {
|
||||
const handler = new ElectronProxyHandler(modelFolderPath);
|
||||
this.model = await tf.loadGraphModel(handler);
|
||||
this.jsonClasses = await handler.loadClasses();
|
||||
}
|
||||
|
||||
// Warmup the model.
|
||||
const result = await this.model.executeAsync(tf.zeros([1, 300, 300, 3])) as tf.Tensor[];
|
||||
result.forEach(async (t) => await t.data());
|
||||
result.forEach(async (t) => t.dispose());
|
||||
this.modelLoaded = true;
|
||||
} catch (err) {
|
||||
this.modelLoaded = false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict Regions from an HTMLImageElement returning list of IRegion.
|
||||
* @param image ImageObject to be used for prediction
|
||||
* @param predictTag Flag indicates if predict only region bounding box of tag too.
|
||||
* @param xRatio Width compression ratio between the HTMLImageElement and the original image.
|
||||
* @param yRatio Height compression ratio between the HTMLImageElement and the original image.
|
||||
*/
|
||||
public async predictImage(image: ImageObject, predictTag: boolean, xRatio: number, yRatio: number)
|
||||
: Promise<IRegion[]> {
|
||||
const regions: IRegion[] = [];
|
||||
|
||||
const predictions = await this.detect(image);
|
||||
predictions.forEach((prediction) => {
|
||||
const left = Math.max(0, prediction.bbox[0] * xRatio);
|
||||
const top = Math.max(0, prediction.bbox[1] * yRatio);
|
||||
const width = Math.max(0, prediction.bbox[2] * xRatio);
|
||||
const height = Math.max(0, prediction.bbox[3] * yRatio);
|
||||
|
||||
regions.push({
|
||||
id: shortid.generate(),
|
||||
type: RegionType.Rectangle,
|
||||
tags: predictTag ? [prediction.class] : [],
|
||||
boundingBox: {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
points: [{
|
||||
x: left,
|
||||
y: top,
|
||||
},
|
||||
{
|
||||
x: left + width,
|
||||
y: top,
|
||||
},
|
||||
{
|
||||
x: left + width,
|
||||
y: top + height,
|
||||
},
|
||||
{
|
||||
x: left,
|
||||
y: top + height,
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect objects for an image returning a list of bounding boxes with
|
||||
* associated class and score.
|
||||
*
|
||||
* @param img The image to detect objects from. Can be a tensor or a DOM
|
||||
* element image, video, or canvas.
|
||||
* @param maxNumBoxes The maximum number of bounding boxes of detected
|
||||
* objects. There can be multiple objects of the same class, but at different
|
||||
* locations. Defaults to 20.
|
||||
*
|
||||
*/
|
||||
public async detect(img: ImageObject, maxNumBoxes: number = 20): Promise<DetectedObject[]> {
|
||||
if (this.model) {
|
||||
return this.infer(img, maxNumBoxes);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers through the model.
|
||||
*
|
||||
* @param img The image to classify. Can be a tensor or a DOM element image,
|
||||
* video, or canvas.
|
||||
* @param maxNumBoxes The maximum number of bounding boxes of detected
|
||||
* objects. There can be multiple objects of the same class, but at different
|
||||
* locations. Defaults to 20.
|
||||
*/
|
||||
private async infer(img: ImageObject, maxNumBoxes: number = 20): Promise<DetectedObject[]> {
|
||||
const batched = tf.tidy(() => {
|
||||
if (!(img instanceof tf.Tensor)) {
|
||||
img = tf.browser.fromPixels(img);
|
||||
}
|
||||
// Reshape to a single-element batch so we can pass it to executeAsync.
|
||||
return img.expandDims(0);
|
||||
});
|
||||
const height = batched.shape[1];
|
||||
const width = batched.shape[2];
|
||||
|
||||
// model returns two tensors:
|
||||
// 1. box classification score with shape of [1, 1917, 90]
|
||||
// 2. box location with shape of [1, 1917, 1, 4]
|
||||
// where 1917 is the number of box detectors, 90 is the number of classes.
|
||||
// and 4 is the four coordinates of the box.
|
||||
const result = await this.model.executeAsync(batched) as tf.Tensor[];
|
||||
|
||||
const scores = result[0].dataSync() as Float32Array;
|
||||
const boxes = result[1].dataSync() as Float32Array;
|
||||
|
||||
// clean the webgl tensors
|
||||
batched.dispose();
|
||||
tf.dispose(result);
|
||||
|
||||
const [maxScores, classes] = this.calculateMaxScores(scores, result[0].shape[1], result[0].shape[2]);
|
||||
|
||||
const prevBackend = tf.getBackend();
|
||||
// run post process in cpu
|
||||
tf.setBackend("cpu");
|
||||
const indexTensor = tf.tidy(() => {
|
||||
const boxes2 = tf.tensor2d(boxes, [result[1].shape[1], result[1].shape[3]]);
|
||||
return tf.image.nonMaxSuppression(boxes2, maxScores, maxNumBoxes, 0.5, 0.5);
|
||||
});
|
||||
|
||||
const indexes = indexTensor.dataSync() as Float32Array;
|
||||
indexTensor.dispose();
|
||||
|
||||
// restore previous backend
|
||||
tf.setBackend(prevBackend);
|
||||
|
||||
return this.buildDetectedObjects(width, height, boxes, maxScores, indexes, classes);
|
||||
}
|
||||
|
||||
private buildDetectedObjects(
|
||||
width: number, height: number, boxes: Float32Array, scores: number[],
|
||||
indexes: Float32Array, classes: number[]): DetectedObject[] {
|
||||
const count = indexes.length;
|
||||
const objects: DetectedObject[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const bbox = [];
|
||||
for (let j = 0; j < 4; j++) {
|
||||
bbox[j] = boxes[indexes[i] * 4 + j];
|
||||
}
|
||||
const minY = bbox[0] * height;
|
||||
const minX = bbox[1] * width;
|
||||
const maxY = bbox[2] * height;
|
||||
const maxX = bbox[3] * width;
|
||||
bbox[0] = minX;
|
||||
bbox[1] = minY;
|
||||
bbox[2] = maxX - minX;
|
||||
bbox[3] = maxY - minY;
|
||||
objects.push({
|
||||
bbox: bbox as [number, number, number, number],
|
||||
class: this.getClass(i, indexes, classes),
|
||||
score: scores[indexes[i]],
|
||||
});
|
||||
}
|
||||
|
||||
return objects;
|
||||
}
|
||||
|
||||
private getClass(index: number, indexes: Float32Array, classes: number[]): string {
|
||||
if (this.jsonClasses && index < indexes.length && indexes[index] < classes.length) {
|
||||
const classId = classes[indexes[index]] - 1;
|
||||
const classObject = this.jsonClasses[classId];
|
||||
|
||||
return classObject ? classObject.displayName : strings.tags.warnings.unknownTagName;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private calculateMaxScores(
|
||||
scores: Float32Array, numBoxes: number,
|
||||
numClasses: number): [number[], number[]] {
|
||||
const maxes = [];
|
||||
const classes = [];
|
||||
for (let i = 0; i < numBoxes; i++) {
|
||||
let max = Number.MIN_VALUE;
|
||||
let index = -1;
|
||||
for (let j = 0; j < numClasses; j++) {
|
||||
if (scores[i * numClasses + j] > max) {
|
||||
max = scores[i * numClasses + j];
|
||||
index = j;
|
||||
}
|
||||
}
|
||||
maxes[i] = max;
|
||||
classes[i] = index;
|
||||
}
|
||||
return [maxes, classes];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
"title": "${strings.export.providers.azureCV.displayName}",
|
||||
"required": [
|
||||
"assetState",
|
||||
"apiKey"
|
||||
"apiKey",
|
||||
"region"
|
||||
],
|
||||
"properties": {
|
||||
"assetState": {
|
||||
@@ -22,6 +23,40 @@
|
||||
"${strings.export.providers.common.properties.assetState.options.tagged}"
|
||||
]
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"title": "${strings.export.providers.azureCV.properties.region.title}",
|
||||
"description": "${strings.export.providers.azureCV.properties.region.description}",
|
||||
"default": "southcentralus",
|
||||
"enum": [
|
||||
"australiaeast",
|
||||
"centralindia",
|
||||
"eastus",
|
||||
"eastus2",
|
||||
"japaneast",
|
||||
"northcentralus",
|
||||
"northeurope",
|
||||
"southcentralus",
|
||||
"southeastasia",
|
||||
"uksouth",
|
||||
"westus2",
|
||||
"westeurope"
|
||||
],
|
||||
"enumNames": [
|
||||
"${strings.export.providers.azureCV.regions.australiaEast}",
|
||||
"${strings.export.providers.azureCV.regions.centralIndia}",
|
||||
"${strings.export.providers.azureCV.regions.eastUs}",
|
||||
"${strings.export.providers.azureCV.regions.eastUs2}",
|
||||
"${strings.export.providers.azureCV.regions.japanEast}",
|
||||
"${strings.export.providers.azureCV.regions.northCentralUs}",
|
||||
"${strings.export.providers.azureCV.regions.northEurope}",
|
||||
"${strings.export.providers.azureCV.regions.southCentralUs}",
|
||||
"${strings.export.providers.azureCV.regions.southeastAsia}",
|
||||
"${strings.export.providers.azureCV.regions.ukSouth}",
|
||||
"${strings.export.providers.azureCV.regions.westUs2}",
|
||||
"${strings.export.providers.azureCV.regions.westEurope}"
|
||||
]
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"title": "${strings.export.providers.azureCV.properties.apiKey.title}"
|
||||
@@ -61,19 +96,20 @@
|
||||
"projectType": {
|
||||
"type": "string",
|
||||
"title": "${strings.export.providers.azureCV.properties.projectType.title}",
|
||||
"default": "Classification",
|
||||
"enum": [
|
||||
"Classification",
|
||||
"Object Detection"
|
||||
"ObjectDetection"
|
||||
],
|
||||
"enumNames": [
|
||||
"${strings.export.providers.azureCV.properties.projectType.options.classification}",
|
||||
"${strings.export.providers.azureCV.properties.projectType.options.objectDetection}"
|
||||
],
|
||||
"default": "Classification"
|
||||
]
|
||||
},
|
||||
"classificationType": {
|
||||
"type": "string",
|
||||
"title": "${strings.export.providers.azureCV.properties.classificationType.title}",
|
||||
"default": "Multilabel",
|
||||
"enum": [
|
||||
"Multilabel",
|
||||
"Multiclass"
|
||||
@@ -81,8 +117,7 @@
|
||||
"enumNames": [
|
||||
"${strings.export.providers.azureCV.properties.classificationType.options.multiLabel}",
|
||||
"${strings.export.providers.azureCV.properties.classificationType.options.multiClass}"
|
||||
],
|
||||
"default": "Multilabel"
|
||||
]
|
||||
},
|
||||
"domainId": {
|
||||
"type": "string",
|
||||
@@ -91,6 +126,8 @@
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"projectType",
|
||||
"classificationType",
|
||||
"domainId"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import shortid from "shortid";
|
||||
import _ from "lodash";
|
||||
import { AzureCustomVisionProvider, IAzureCustomVisionExportOptions, NewOrExisting } from "./azureCustomVision";
|
||||
import {
|
||||
AzureCustomVisionProvider, IAzureCustomVisionExportOptions,
|
||||
NewOrExisting, AzureRegion,
|
||||
} from "./azureCustomVision";
|
||||
import registerProviders from "../../registerProviders";
|
||||
import { ExportProviderFactory } from "./exportProviderFactory";
|
||||
import MockFactory from "../../common/mockFactory";
|
||||
import {
|
||||
IProject, AssetState, IAsset, IAssetMetadata,
|
||||
RegionType, IRegion, IExportProviderOptions, AssetType,
|
||||
RegionType, IRegion, IExportProviderOptions,
|
||||
} from "../../models/applicationState";
|
||||
import { ExportAssetState } from "./exportProvider";
|
||||
jest.mock("./azureCustomVision/azureCustomVisionService");
|
||||
@@ -29,6 +32,7 @@ describe("Azure Custom Vision Export Provider", () => {
|
||||
let testProject: IProject = null;
|
||||
const defaultOptions: IAzureCustomVisionExportOptions = {
|
||||
apiKey: expect.any(String),
|
||||
region: AzureRegion.SouthCentralUS,
|
||||
assetState: ExportAssetState.All,
|
||||
newOrExisting: NewOrExisting.New,
|
||||
projectId: expect.any(String),
|
||||
@@ -64,6 +68,7 @@ describe("Azure Custom Vision Export Provider", () => {
|
||||
assetState: ExportAssetState.All,
|
||||
projectId: "azure-custom-vision-project-1",
|
||||
apiKey: "ABC123",
|
||||
region: AzureRegion.SouthCentralUS,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -81,6 +86,18 @@ describe("Azure Custom Vision Export Provider", () => {
|
||||
expect(provider).toBeInstanceOf(AzureCustomVisionProvider);
|
||||
});
|
||||
|
||||
it("Constructs custom vision service with correct options", () => {
|
||||
const customVisionMock = AzureCustomVisionService as jest.Mocked<typeof AzureCustomVisionService>;
|
||||
const providerOptions = testProject.exportFormat.providerOptions as IAzureCustomVisionExportOptions;
|
||||
providerOptions.region = AzureRegion.WestEurope;
|
||||
createProvider(testProject);
|
||||
|
||||
expect(customVisionMock).toBeCalledWith({
|
||||
apiKey: providerOptions.apiKey,
|
||||
baseUrl: `https://${providerOptions.region}.api.cognitive.microsoft.com/customvision/v2.2/Training`,
|
||||
});
|
||||
});
|
||||
|
||||
it("Calling save with New project creates Azure Custom Vision project", async () => {
|
||||
const customVisionMock = AzureCustomVisionService as jest.Mocked<typeof AzureCustomVisionService>;
|
||||
customVisionMock.prototype.create = jest.fn((project) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import HtmlFileReader from "../../common/htmlFileReader";
|
||||
export interface IAzureCustomVisionExportOptions extends IExportProviderOptions {
|
||||
assetState: ExportAssetState;
|
||||
newOrExisting: NewOrExisting;
|
||||
region: AzureRegion;
|
||||
apiKey: string;
|
||||
projectId?: string;
|
||||
name?: string;
|
||||
@@ -38,6 +39,24 @@ export enum NewOrExisting {
|
||||
Existing = "existing",
|
||||
}
|
||||
|
||||
/**
|
||||
* Azure regions
|
||||
*/
|
||||
export enum AzureRegion {
|
||||
EastUS = "eastus",
|
||||
EastUS2 = "eastus2",
|
||||
NorthCentralUS = "northcentralus",
|
||||
SouthCentralUS = "southcentralus",
|
||||
WestUS2 = "westus2",
|
||||
WestEurope = "westeurope",
|
||||
NorthEurope = "northeurope",
|
||||
SoutheastAsia = "southeastasia",
|
||||
AustraliaEast = "australiaeast",
|
||||
CentralIndia = "centralindia",
|
||||
UKSouth = "uksouth",
|
||||
JapanEast = "japaneast",
|
||||
}
|
||||
|
||||
/**
|
||||
* @name - Azure Custom Vision Provider
|
||||
* @description - Exports a VoTT project into an Azure custom vision project
|
||||
@@ -49,9 +68,13 @@ export class AzureCustomVisionProvider extends ExportProvider<IAzureCustomVision
|
||||
super(project, options);
|
||||
Guard.null(options);
|
||||
|
||||
if (!options.region) {
|
||||
options.region = AzureRegion.SouthCentralUS;
|
||||
}
|
||||
|
||||
const cusomVisionServiceOptions: IAzureCustomVisionServiceOptions = {
|
||||
apiKey: options.apiKey,
|
||||
baseUrl: "https://southcentralus.api.cognitive.microsoft.com/customvision/v2.2/Training",
|
||||
baseUrl: `https://${options.region}.api.cognitive.microsoft.com/customvision/v2.2/Training`,
|
||||
};
|
||||
this.customVisionService = new AzureCustomVisionService(cusomVisionServiceOptions);
|
||||
}
|
||||
@@ -111,6 +134,7 @@ export class AzureCustomVisionProvider extends ExportProvider<IAzureCustomVision
|
||||
|
||||
return {
|
||||
assetState: customVisionOptions.assetState,
|
||||
region: customVisionOptions.region,
|
||||
apiKey: customVisionOptions.apiKey,
|
||||
projectId: customVisionProject.id,
|
||||
newOrExisting: NewOrExisting.Existing,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"ui:widget": "externalPicker",
|
||||
"ui:options": {
|
||||
"method": "GET",
|
||||
"url": "https://southcentralus.api.cognitive.microsoft.com/customvision/v2.2/Training/projects",
|
||||
"url": "https://${props.formContext.providerOptions.region}.api.cognitive.microsoft.com/customvision/v2.2/Training/projects",
|
||||
"authHeaderName": "Training-key",
|
||||
"authHeaderValue": "${props.formContext.providerOptions.apiKey}",
|
||||
"keySelector": "${item.id}",
|
||||
@@ -20,11 +20,16 @@
|
||||
"ui:widget": "externalPicker",
|
||||
"ui:options": {
|
||||
"method": "GET",
|
||||
"url": "https://southcentralus.api.cognitive.microsoft.com/customvision/v2.2/Training/domains",
|
||||
"url": "https://${props.formContext.providerOptions.region}.api.cognitive.microsoft.com/customvision/v2.2/Training/domains",
|
||||
"authHeaderName": "Training-key",
|
||||
"authHeaderValue": "${props.formContext.providerOptions.apiKey}",
|
||||
"keySelector": "${item.id}",
|
||||
"valueSelector": "${item.name}"
|
||||
"valueSelector": "${item.name}",
|
||||
"filter": {
|
||||
"left": "${item.type}",
|
||||
"operator": "eq",
|
||||
"right": "${props.formContext.providerOptions.projectType}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"type": "object",
|
||||
"title": "${strings.export.providers.cntk.displayName}",
|
||||
"properties": {
|
||||
"assetState": {
|
||||
"type": "string",
|
||||
"title": "${strings.export.providers.common.properties.assetState.title}",
|
||||
"description": "${strings.export.providers.common.properties.assetState.description}",
|
||||
"enum": [
|
||||
"all",
|
||||
"visited",
|
||||
"tagged"
|
||||
],
|
||||
"default": "visited",
|
||||
"enumNames": [
|
||||
"${strings.export.providers.common.properties.assetState.options.all}",
|
||||
"${strings.export.providers.common.properties.assetState.options.visited}",
|
||||
"${strings.export.providers.common.properties.assetState.options.tagged}"
|
||||
]
|
||||
},
|
||||
"testTrainSplit": {
|
||||
"title": "${strings.export.providers.common.properties.testTrainSplit.title}",
|
||||
"description": "${strings.export.providers.common.properties.testTrainSplit.description}",
|
||||
"type": "number",
|
||||
"default": 80
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import _ from "lodash";
|
||||
import os from "os";
|
||||
import { CntkExportProvider, ICntkExportProviderOptions } from "./cntk";
|
||||
import { IProject, AssetState, IAssetMetadata } from "../../models/applicationState";
|
||||
import { AssetProviderFactory } from "../storage/assetProviderFactory";
|
||||
import { ExportAssetState } from "./exportProvider";
|
||||
import MockFactory from "../../common/mockFactory";
|
||||
import registerMixins from "../../registerMixins";
|
||||
import registerProviders from "../../registerProviders";
|
||||
import { ExportProviderFactory } from "./exportProviderFactory";
|
||||
jest.mock("../../services/assetService");
|
||||
import { AssetService } from "../../services/assetService";
|
||||
|
||||
jest.mock("../storage/localFileSystemProxy");
|
||||
import { LocalFileSystemProxy } from "../storage/localFileSystemProxy";
|
||||
import HtmlFileReader from "../../common/htmlFileReader";
|
||||
import { appInfo } from "../../common/appInfo";
|
||||
|
||||
describe("CNTK Export Provider", () => {
|
||||
const testAssets = MockFactory.createTestAssets(10, 1);
|
||||
let testProject: IProject = null;
|
||||
|
||||
const defaultOptions: ICntkExportProviderOptions = {
|
||||
assetState: ExportAssetState.Tagged,
|
||||
testTrainSplit: 80,
|
||||
};
|
||||
|
||||
function createProvider(project: IProject): CntkExportProvider {
|
||||
return new CntkExportProvider(
|
||||
project,
|
||||
project.exportFormat.providerOptions as ICntkExportProviderOptions,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
registerMixins();
|
||||
registerProviders();
|
||||
|
||||
HtmlFileReader.getAssetBlob = jest.fn(() => {
|
||||
return Promise.resolve(new Blob(["Some binary data"]));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
testAssets.forEach((asset) => {
|
||||
asset.state = AssetState.Tagged;
|
||||
});
|
||||
|
||||
testProject = {
|
||||
...MockFactory.createTestProject("TestProject"),
|
||||
assets: _.keyBy(testAssets, (a) => a.id),
|
||||
exportFormat: {
|
||||
providerType: "cntk",
|
||||
providerOptions: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
AssetProviderFactory.create = jest.fn(() => {
|
||||
return {
|
||||
getAssets: jest.fn(() => Promise.resolve(testAssets)),
|
||||
};
|
||||
});
|
||||
|
||||
const assetServiceMock = AssetService as jest.Mocked<typeof AssetService>;
|
||||
assetServiceMock.prototype.getAssetMetadata = jest.fn((asset) => {
|
||||
const assetMetadata = {
|
||||
asset: { ...asset },
|
||||
regions: [
|
||||
MockFactory.createTestRegion("region-1", ["tag1"]),
|
||||
MockFactory.createTestRegion("region-2", ["tag1"]),
|
||||
],
|
||||
version: appInfo.version,
|
||||
};
|
||||
|
||||
return Promise.resolve(assetMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
it("Is defined", () => {
|
||||
expect(CntkExportProvider).toBeDefined();
|
||||
});
|
||||
|
||||
it("Can be instantiated through the factory", () => {
|
||||
const options: ICntkExportProviderOptions = {
|
||||
assetState: ExportAssetState.All,
|
||||
testTrainSplit: 80,
|
||||
};
|
||||
const exportProvider = ExportProviderFactory.create("cntk", testProject, options);
|
||||
expect(exportProvider).not.toBeNull();
|
||||
expect(exportProvider).toBeInstanceOf(CntkExportProvider);
|
||||
});
|
||||
|
||||
it("Creates correct folder structure", async () => {
|
||||
const provider = createProvider(testProject);
|
||||
await provider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
const createContainerCalls = storageProviderMock.mock.instances[0].createContainer.mock.calls;
|
||||
const createContainerArgs = createContainerCalls.map((args) => args[0]);
|
||||
|
||||
const expectedFolderPath = "Project-TestProject-CNTK-export";
|
||||
expect(createContainerArgs).toContain(expectedFolderPath);
|
||||
expect(createContainerArgs).toContain(`${expectedFolderPath}/positive`);
|
||||
expect(createContainerArgs).toContain(`${expectedFolderPath}/negative`);
|
||||
expect(createContainerArgs).toContain(`${expectedFolderPath}/testImages`);
|
||||
});
|
||||
|
||||
it("Writes export files to storage provider", async () => {
|
||||
const provider = createProvider(testProject);
|
||||
const getAssetsSpy = jest.spyOn(provider, "getAssetsForExport");
|
||||
|
||||
await provider.export();
|
||||
|
||||
const assetsToExport = await getAssetsSpy.mock.results[0].value;
|
||||
const testSplit = (100 - (defaultOptions.testTrainSplit || 80)) / 100;
|
||||
const testCount = Math.ceil(assetsToExport.length * testSplit);
|
||||
const testArray = assetsToExport.slice(0, testCount);
|
||||
const trainArray = assetsToExport.slice(testCount, assetsToExport.length);
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
const writeBinaryCalls = storageProviderMock.mock.instances[0].writeBinary.mock.calls;
|
||||
const writeTextFileCalls = storageProviderMock.mock.instances[0].writeText.mock.calls;
|
||||
|
||||
expect(writeBinaryCalls).toHaveLength(testAssets.length);
|
||||
expect(writeTextFileCalls).toHaveLength(testAssets.length * 2);
|
||||
|
||||
testArray.forEach((assetMetadata) => {
|
||||
const testFolderPath = "Project-TestProject-CNTK-export/testImages";
|
||||
assertExportedAsset(testFolderPath, assetMetadata);
|
||||
});
|
||||
|
||||
trainArray.forEach((assetMetadata) => {
|
||||
const trainFolderPath = "Project-TestProject-CNTK-export/positive";
|
||||
assertExportedAsset(trainFolderPath, assetMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
function assertExportedAsset(folderPath: string, assetMetadata: IAssetMetadata) {
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
const writeBinaryCalls = storageProviderMock.mock.instances[0].writeBinary.mock.calls;
|
||||
const writeBinaryFilenameArgs = writeBinaryCalls.map((args) => args[0]);
|
||||
const writeTextFileCalls = storageProviderMock.mock.instances[0].writeText.mock.calls;
|
||||
const writeTextFilenameArgs = writeTextFileCalls.map((args) => args[0]);
|
||||
|
||||
expect(writeBinaryFilenameArgs).toContain(`${folderPath}/${assetMetadata.asset.name}`);
|
||||
expect(writeTextFilenameArgs).toContain(`${folderPath}/${assetMetadata.asset.name}.bboxes.labels.tsv`);
|
||||
expect(writeTextFilenameArgs).toContain(`${folderPath}/${assetMetadata.asset.name}.bboxes.tsv`);
|
||||
|
||||
const writeLabelsCall = writeTextFileCalls
|
||||
.find((args: string[]) => args[0].indexOf(`${assetMetadata.asset.name}.bboxes.labels.tsv`) >= 0);
|
||||
|
||||
const writeBoxesCall = writeTextFileCalls
|
||||
.find((args: string[]) => args[0].indexOf(`${assetMetadata.asset.name}.bboxes.tsv`) >= 0);
|
||||
|
||||
const expectedLabelData = `${assetMetadata.regions[0].tags[0]}${os.EOL}${assetMetadata.regions[1].tags[0]}`;
|
||||
expect(writeLabelsCall[1]).toEqual(expectedLabelData);
|
||||
|
||||
const expectedBoxData = [];
|
||||
// tslint:disable-next-line:max-line-length
|
||||
expectedBoxData.push(`${assetMetadata.regions[0].boundingBox.left}\t${assetMetadata.regions[0].boundingBox.left + assetMetadata.regions[0].boundingBox.width}\t${assetMetadata.regions[0].boundingBox.top}\t${assetMetadata.regions[0].boundingBox.top + assetMetadata.regions[0].boundingBox.height}`);
|
||||
// tslint:disable-next-line:max-line-length
|
||||
expectedBoxData.push(`${assetMetadata.regions[1].boundingBox.left}\t${assetMetadata.regions[1].boundingBox.left + assetMetadata.regions[1].boundingBox.width}\t${assetMetadata.regions[1].boundingBox.top}\t${assetMetadata.regions[1].boundingBox.top + assetMetadata.regions[1].boundingBox.height}`);
|
||||
expect(writeBoxesCall[1]).toEqual(expectedBoxData.join(os.EOL));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import os from "os";
|
||||
import { ExportProvider, IExportResults } from "./exportProvider";
|
||||
import { IAssetMetadata, IExportProviderOptions, IProject } from "../../models/applicationState";
|
||||
import HtmlFileReader from "../../common/htmlFileReader";
|
||||
import Guard from "../../common/guard";
|
||||
|
||||
enum ExportSplit {
|
||||
Test,
|
||||
Train,
|
||||
}
|
||||
|
||||
/**
|
||||
* Export options for CNTK export provider
|
||||
*/
|
||||
export interface ICntkExportProviderOptions extends IExportProviderOptions {
|
||||
/** The test / train split ratio for exporting data */
|
||||
testTrainSplit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CNTK Export provider
|
||||
*/
|
||||
export class CntkExportProvider extends ExportProvider<ICntkExportProviderOptions> {
|
||||
private exportFolderName: string;
|
||||
|
||||
constructor(project: IProject, options: ICntkExportProviderOptions) {
|
||||
super(project, options);
|
||||
Guard.null(options);
|
||||
|
||||
this.exportFolderName = `${this.project.name.replace(/\s/g, "-")}-CNTK-export`;
|
||||
}
|
||||
|
||||
public async export(): Promise<IExportResults> {
|
||||
await this.createFolderStructure();
|
||||
const assetsToExport = await this.getAssetsForExport();
|
||||
const testSplit = (100 - (this.options.testTrainSplit || 80)) / 100;
|
||||
const testCount = Math.ceil(assetsToExport.length * testSplit);
|
||||
const testArray = assetsToExport.slice(0, testCount);
|
||||
|
||||
const results = await assetsToExport.mapAsync(async (assetMetadata) => {
|
||||
try {
|
||||
const exportSplit = testArray.find((am) => am.asset.id === assetMetadata.asset.id)
|
||||
? ExportSplit.Test
|
||||
: ExportSplit.Train;
|
||||
|
||||
await this.exportAssetFrame(assetMetadata, exportSplit);
|
||||
return {
|
||||
asset: assetMetadata,
|
||||
success: true,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
asset: assetMetadata,
|
||||
success: false,
|
||||
error: e,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
completed: results.filter((r) => r.success),
|
||||
errors: results.filter((r) => !r.success),
|
||||
count: results.length,
|
||||
};
|
||||
}
|
||||
|
||||
private async exportAssetFrame(assetMetadata: IAssetMetadata, exportSplit: ExportSplit) {
|
||||
const labelData = [];
|
||||
const boundingBoxData = [];
|
||||
|
||||
assetMetadata.regions.forEach((region) => {
|
||||
region.tags.forEach((tagName) => {
|
||||
labelData.push(tagName);
|
||||
// tslint:disable-next-line:max-line-length
|
||||
boundingBoxData.push(`${region.boundingBox.left}\t${region.boundingBox.left + region.boundingBox.width}\t${region.boundingBox.top}\t${region.boundingBox.top + region.boundingBox.height}`);
|
||||
});
|
||||
});
|
||||
|
||||
const buffer = await HtmlFileReader.getAssetArray(assetMetadata.asset);
|
||||
const folderName = exportSplit === ExportSplit.Train ? "positive" : "testImages";
|
||||
const labelsPath = `${this.exportFolderName}/${folderName}/${assetMetadata.asset.name}.bboxes.labels.tsv`;
|
||||
const boundingBoxPath = `${this.exportFolderName}/${folderName}/${assetMetadata.asset.name}.bboxes.tsv`;
|
||||
const binaryPath = `${this.exportFolderName}/${folderName}/${assetMetadata.asset.name}`;
|
||||
|
||||
await Promise.all([
|
||||
this.storageProvider.writeText(labelsPath, labelData.join(os.EOL)),
|
||||
this.storageProvider.writeText(boundingBoxPath, boundingBoxData.join(os.EOL)),
|
||||
this.storageProvider.writeBinary(binaryPath, Buffer.from(buffer)),
|
||||
]);
|
||||
}
|
||||
|
||||
private async createFolderStructure(): Promise<void> {
|
||||
const positiveFolder = `${this.exportFolderName}/positive`;
|
||||
const negativeFolder = `${this.exportFolderName}/negative`;
|
||||
const testImagesFolder = `${this.exportFolderName}/testImages`;
|
||||
|
||||
await this.storageProvider.createContainer(this.exportFolderName);
|
||||
|
||||
await [positiveFolder, negativeFolder, testImagesFolder]
|
||||
.forEachAsync(async (folderPath) => {
|
||||
await this.storageProvider.createContainer(folderPath);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"testTrainSplit": {
|
||||
"ui:widget": "slider"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"type": "object",
|
||||
"title": "${strings.export.providers.csv.displayName}",
|
||||
"properties": {
|
||||
"assetState": {
|
||||
"type": "string",
|
||||
"title": "${strings.export.providers.common.properties.assetState.title}",
|
||||
"description": "${strings.export.providers.common.properties.assetState.description}",
|
||||
"enum": [
|
||||
"all",
|
||||
"visited",
|
||||
"tagged"
|
||||
],
|
||||
"default": "visited",
|
||||
"enumNames": [
|
||||
"${strings.export.providers.common.properties.assetState.options.all}",
|
||||
"${strings.export.providers.common.properties.assetState.options.visited}",
|
||||
"${strings.export.providers.common.properties.assetState.options.tagged}"
|
||||
]
|
||||
},
|
||||
"includeImages": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"title": "${strings.export.providers.common.properties.includeImages.title}",
|
||||
"description": "${strings.export.providers.common.properties.includeImages.description}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import _ from "lodash";
|
||||
import { CsvExportProvider, ICsvExportProviderOptions } from "./csv";
|
||||
import registerProviders from "../../registerProviders";
|
||||
import { ExportAssetState } from "./exportProvider";
|
||||
import { ExportProviderFactory } from "./exportProviderFactory";
|
||||
import {
|
||||
IProject, IAssetMetadata, AssetState, IExportProviderOptions,
|
||||
RegionType,
|
||||
} from "../../models/applicationState";
|
||||
import MockFactory from "../../common/mockFactory";
|
||||
|
||||
jest.mock("../../services/assetService");
|
||||
import { AssetService } from "../../services/assetService";
|
||||
|
||||
jest.mock("../storage/localFileSystemProxy");
|
||||
import { LocalFileSystemProxy } from "../storage/localFileSystemProxy";
|
||||
import registerMixins from "../../registerMixins";
|
||||
import HtmlFileReader from "../../common/htmlFileReader";
|
||||
import { appInfo } from "../../common/appInfo";
|
||||
import { AssetProviderFactory } from "../storage/assetProviderFactory";
|
||||
import os from "os";
|
||||
|
||||
registerMixins();
|
||||
|
||||
describe("CSV Format Export Provider", () => {
|
||||
const testAssets = MockFactory.createTestAssets(10, 1);
|
||||
const testProject: IProject = {
|
||||
...MockFactory.createTestProject(),
|
||||
assets: {
|
||||
"asset-1": MockFactory.createTestAsset("1", AssetState.Tagged),
|
||||
"asset-2": MockFactory.createTestAsset("2", AssetState.Tagged),
|
||||
"asset-3": MockFactory.createTestAsset("3", AssetState.Visited),
|
||||
"asset-4": MockFactory.createTestAsset("4", AssetState.NotVisited),
|
||||
},
|
||||
exportFormat: {
|
||||
providerType: "csv",
|
||||
providerOptions: {
|
||||
assetState: ExportAssetState.All,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const expectedFileName = "vott-csv-export/" + testProject.name.replace(" ", "-") + "-export.csv";
|
||||
|
||||
beforeAll(() => {
|
||||
HtmlFileReader.getAssetBlob = jest.fn(() => {
|
||||
return Promise.resolve(new Blob(["Some binary data"]));
|
||||
});
|
||||
|
||||
AssetProviderFactory.create = jest.fn(() => {
|
||||
return {
|
||||
getAssets: jest.fn(() => Promise.resolve(testAssets)),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
registerProviders();
|
||||
});
|
||||
|
||||
it("Is defined", () => {
|
||||
expect(CsvExportProvider).toBeDefined();
|
||||
});
|
||||
|
||||
it("Can be instantiated through the factory", () => {
|
||||
const options: IExportProviderOptions = {
|
||||
assetState: ExportAssetState.All,
|
||||
};
|
||||
const exportProvider = ExportProviderFactory.create("csv", testProject, options);
|
||||
expect(exportProvider).not.toBeNull();
|
||||
expect(exportProvider).toBeInstanceOf(CsvExportProvider);
|
||||
});
|
||||
|
||||
describe("Export variations", () => {
|
||||
beforeEach(() => {
|
||||
const assetServiceMock = AssetService as jest.Mocked<typeof AssetService>;
|
||||
assetServiceMock.prototype.getAssetMetadata = jest.fn((asset) => {
|
||||
const assetMetadata: IAssetMetadata = {
|
||||
asset,
|
||||
regions: [
|
||||
{
|
||||
id: "1",
|
||||
type: RegionType.Rectangle,
|
||||
tags: ["a", "b"],
|
||||
boundingBox: {
|
||||
left: 1,
|
||||
top: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
version: appInfo.version,
|
||||
};
|
||||
|
||||
return Promise.resolve(assetMetadata);
|
||||
});
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as jest.Mock<LocalFileSystemProxy>;
|
||||
storageProviderMock.prototype.writeText.mockClear();
|
||||
storageProviderMock.prototype.writeBinary.mockClear();
|
||||
storageProviderMock.mockClear();
|
||||
});
|
||||
|
||||
it("Exports all assets", async () => {
|
||||
const options: ICsvExportProviderOptions = {
|
||||
assetState: ExportAssetState.All,
|
||||
includeImages: false,
|
||||
};
|
||||
|
||||
const exportProvider = new CsvExportProvider(testProject, options);
|
||||
await exportProvider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
const exportCsv = storageProviderMock.mock.instances[0].writeText.mock.calls[0][1];
|
||||
const records = exportCsv.split(os.EOL);
|
||||
|
||||
// 10 assets - Each with 1 region and 2 tags
|
||||
expect(records.length).toEqual(testAssets.length * 2 + 1);
|
||||
|
||||
expect(LocalFileSystemProxy.prototype.writeText)
|
||||
.toBeCalledWith(expectedFileName, expect.any(String));
|
||||
});
|
||||
|
||||
it("Exports only visited assets (includes tagged)", async () => {
|
||||
const options: ICsvExportProviderOptions = {
|
||||
assetState: ExportAssetState.Visited,
|
||||
includeImages: false,
|
||||
};
|
||||
|
||||
const exportProvider = new CsvExportProvider(testProject, options);
|
||||
await exportProvider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
const exportCsv = storageProviderMock.mock.instances[0].writeText.mock.calls[0][1];
|
||||
const records = exportCsv.split(os.EOL);
|
||||
|
||||
// 2 tagged / 1 visited assets - Each with 1 region and 2 tags
|
||||
expect(records.length).toEqual(7);
|
||||
});
|
||||
|
||||
it("Exports only tagged assets", async () => {
|
||||
const options: ICsvExportProviderOptions = {
|
||||
assetState: ExportAssetState.Tagged,
|
||||
includeImages: false,
|
||||
};
|
||||
|
||||
const exportProvider = new CsvExportProvider(testProject, options);
|
||||
await exportProvider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
const exportCsv = storageProviderMock.mock.instances[0].writeText.mock.calls[0][1];
|
||||
const records = exportCsv.split(os.EOL);
|
||||
|
||||
// 2 tagged - Each with 1 region and 2 tags
|
||||
expect(records.length).toEqual(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import _ from "lodash";
|
||||
import { ExportProvider } from "./exportProvider";
|
||||
import { IProject, IExportProviderOptions } from "../../models/applicationState";
|
||||
import Guard from "../../common/guard";
|
||||
import HtmlFileReader from "../../common/htmlFileReader";
|
||||
import json2csv, { Parser } from "json2csv";
|
||||
|
||||
/**
|
||||
* Options for CSV Export Provider
|
||||
*/
|
||||
export interface ICsvExportProviderOptions extends IExportProviderOptions {
|
||||
/** Whether or not to include binary assets in target connection */
|
||||
includeImages: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name - CSV Format Export Provider
|
||||
* @description - Exports a project into a single CSV file that include all configured assets
|
||||
*/
|
||||
export class CsvExportProvider extends ExportProvider<ICsvExportProviderOptions> {
|
||||
constructor(project: IProject, options: ICsvExportProviderOptions) {
|
||||
super(project, options);
|
||||
Guard.null(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export project to CSV
|
||||
*/
|
||||
public async export(): Promise<void> {
|
||||
const results = await this.getAssetsForExport();
|
||||
const dataItems = [];
|
||||
|
||||
await results.forEachAsync(async (assetMetadata) => {
|
||||
if (this.options.includeImages) {
|
||||
// Write Image
|
||||
const arrayBuffer = await HtmlFileReader.getAssetArray(assetMetadata.asset);
|
||||
const assetFilePath = `vott-csv-export/${assetMetadata.asset.name}`;
|
||||
await this.storageProvider.writeBinary(assetFilePath, Buffer.from(arrayBuffer));
|
||||
}
|
||||
|
||||
// Push CSV Records
|
||||
// The CSV file itself must have the following format::
|
||||
// image,xmin,ymin,xmax,ymax,label
|
||||
// image_1.jpg,26,594,86,617,cat
|
||||
// image_1.jpg,599,528,612,541,car
|
||||
// image_2.jpg,393,477,430,552,dog
|
||||
assetMetadata.regions.forEach((region) => {
|
||||
region.tags.forEach((tag) => {
|
||||
const dataItem = {
|
||||
image: assetMetadata.asset.name,
|
||||
xmin: region.boundingBox.left,
|
||||
ymin: region.boundingBox.top,
|
||||
xmax: region.boundingBox.left + region.boundingBox.width,
|
||||
ymax: region.boundingBox.top + region.boundingBox.height,
|
||||
label: tag,
|
||||
};
|
||||
dataItems.push(dataItem);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Configure CSV options
|
||||
const csvOptions: json2csv.Options<{}> = {
|
||||
fields: ["image", "xmin", "ymin", "xmax", "ymax", "label"],
|
||||
};
|
||||
const csvParser = new Parser(csvOptions);
|
||||
const csvData = csvParser.parse(dataItems);
|
||||
|
||||
// Save CSV
|
||||
const fileName = `vott-csv-export/${this.project.name.replace(/\s/g, "-")}-export.csv`;
|
||||
await this.storageProvider.writeText(fileName, csvData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"includeImages": {
|
||||
"ui:widget": "checkbox"
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "object",
|
||||
"title": "${strings.export.providers.tfPascalVoc.displayName}",
|
||||
"title": "${strings.export.providers.pascalVoc.displayName}",
|
||||
"properties": {
|
||||
"assetState": {
|
||||
"type": "string",
|
||||
@@ -19,14 +19,14 @@
|
||||
]
|
||||
},
|
||||
"testTrainSplit": {
|
||||
"title": "${strings.export.providers.tfPascalVoc.testTrainSplit.title}",
|
||||
"description": "${strings.export.providers.tfPascalVoc.testTrainSplit.description}",
|
||||
"title": "${strings.export.providers.common.properties.testTrainSplit.title}",
|
||||
"description": "${strings.export.providers.common.properties.testTrainSplit.description}",
|
||||
"type": "number",
|
||||
"default": 80
|
||||
},
|
||||
"exportUnassigned": {
|
||||
"title": "${strings.export.providers.tfPascalVoc.exportUnassigned.title}",
|
||||
"description": "${strings.export.providers.tfPascalVoc.exportUnassigned.description}",
|
||||
"title": "${strings.export.providers.pascalVoc.exportUnassigned.title}",
|
||||
"description": "${strings.export.providers.pascalVoc.exportUnassigned.description}",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
+20
-20
@@ -1,5 +1,5 @@
|
||||
import _ from "lodash";
|
||||
import { TFPascalVOCExportProvider, ITFPascalVOCExportProviderOptions } from "./tensorFlowPascalVOC";
|
||||
import { PascalVOCExportProvider, IPascalVOCExportProviderOptions } from "./pascalVOC";
|
||||
import { ExportAssetState } from "./exportProvider";
|
||||
import registerProviders from "../../registerProviders";
|
||||
import { ExportProviderFactory } from "./exportProviderFactory";
|
||||
@@ -21,7 +21,7 @@ import { AssetProviderFactory } from "../storage/assetProviderFactory";
|
||||
|
||||
registerMixins();
|
||||
|
||||
describe("TFPascalVOC Json Export Provider", () => {
|
||||
describe("PascalVOC Json Export Provider", () => {
|
||||
const testAssets = MockFactory.createTestAssets(10, 1);
|
||||
const baseTestProject = MockFactory.createTestProject("Test Project");
|
||||
baseTestProject.assets = {
|
||||
@@ -51,18 +51,18 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
});
|
||||
|
||||
it("Is defined", () => {
|
||||
expect(TFPascalVOCExportProvider).toBeDefined();
|
||||
expect(PascalVOCExportProvider).toBeDefined();
|
||||
});
|
||||
|
||||
it("Can be instantiated through the factory", () => {
|
||||
const options: ITFPascalVOCExportProviderOptions = {
|
||||
const options: IPascalVOCExportProviderOptions = {
|
||||
assetState: ExportAssetState.All,
|
||||
exportUnassigned: true,
|
||||
testTrainSplit: 80,
|
||||
};
|
||||
const exportProvider = ExportProviderFactory.create("tensorFlowPascalVOC", baseTestProject, options);
|
||||
const exportProvider = ExportProviderFactory.create("pascalVOC", baseTestProject, options);
|
||||
expect(exportProvider).not.toBeNull();
|
||||
expect(exportProvider).toBeInstanceOf(TFPascalVOCExportProvider);
|
||||
expect(exportProvider).toBeInstanceOf(PascalVOCExportProvider);
|
||||
});
|
||||
|
||||
describe("Export variations", () => {
|
||||
@@ -87,7 +87,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
});
|
||||
|
||||
it("Exports all assets", async () => {
|
||||
const options: ITFPascalVOCExportProviderOptions = {
|
||||
const options: IPascalVOCExportProviderOptions = {
|
||||
assetState: ExportAssetState.All,
|
||||
exportUnassigned: true,
|
||||
testTrainSplit: 80,
|
||||
@@ -96,7 +96,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
const testProject = { ...baseTestProject };
|
||||
testProject.tags = MockFactory.createTestTags(3);
|
||||
|
||||
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
|
||||
const exportProvider = new PascalVOCExportProvider(testProject, options);
|
||||
await exportProvider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
@@ -145,7 +145,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
});
|
||||
|
||||
it("Exports only visited assets (includes tagged)", async () => {
|
||||
const options: ITFPascalVOCExportProviderOptions = {
|
||||
const options: IPascalVOCExportProviderOptions = {
|
||||
assetState: ExportAssetState.Visited,
|
||||
exportUnassigned: true,
|
||||
testTrainSplit: 80,
|
||||
@@ -154,7 +154,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
const testProject = { ...baseTestProject };
|
||||
testProject.tags = MockFactory.createTestTags(1);
|
||||
|
||||
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
|
||||
const exportProvider = new PascalVOCExportProvider(testProject, options);
|
||||
await exportProvider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
@@ -191,7 +191,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
});
|
||||
|
||||
it("Exports only tagged assets", async () => {
|
||||
const options: ITFPascalVOCExportProviderOptions = {
|
||||
const options: IPascalVOCExportProviderOptions = {
|
||||
assetState: ExportAssetState.Tagged,
|
||||
exportUnassigned: true,
|
||||
testTrainSplit: 80,
|
||||
@@ -200,7 +200,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
const testProject = { ...baseTestProject };
|
||||
testProject.tags = MockFactory.createTestTags(3);
|
||||
|
||||
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
|
||||
const exportProvider = new PascalVOCExportProvider(testProject, options);
|
||||
await exportProvider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
@@ -242,7 +242,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
});
|
||||
|
||||
it("Export includes unassigned tags", async () => {
|
||||
const options: ITFPascalVOCExportProviderOptions = {
|
||||
const options: IPascalVOCExportProviderOptions = {
|
||||
assetState: ExportAssetState.Tagged,
|
||||
exportUnassigned: true,
|
||||
testTrainSplit: 80,
|
||||
@@ -254,7 +254,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
testProject.assets = _.keyBy(testAssets, (asset) => asset.id);
|
||||
testProject.tags = MockFactory.createTestTags(3);
|
||||
|
||||
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
|
||||
const exportProvider = new PascalVOCExportProvider(testProject, options);
|
||||
await exportProvider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
@@ -275,7 +275,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
});
|
||||
|
||||
it("Export does not include unassigned tags", async () => {
|
||||
const options: ITFPascalVOCExportProviderOptions = {
|
||||
const options: IPascalVOCExportProviderOptions = {
|
||||
assetState: ExportAssetState.Tagged,
|
||||
exportUnassigned: false,
|
||||
testTrainSplit: 80,
|
||||
@@ -287,7 +287,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
testProject.assets = _.keyBy(testAssets, (asset) => asset.id);
|
||||
testProject.tags = MockFactory.createTestTags(3);
|
||||
|
||||
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
|
||||
const exportProvider = new PascalVOCExportProvider(testProject, options);
|
||||
await exportProvider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
@@ -309,7 +309,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
|
||||
describe("Annotations", () => {
|
||||
it("contains expected XML", async () => {
|
||||
const options: ITFPascalVOCExportProviderOptions = {
|
||||
const options: IPascalVOCExportProviderOptions = {
|
||||
assetState: ExportAssetState.Tagged,
|
||||
exportUnassigned: false,
|
||||
testTrainSplit: 80,
|
||||
@@ -321,7 +321,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
testProject.assets = _.keyBy(testAssets, (asset) => asset.id);
|
||||
testProject.tags = [MockFactory.createTestTag("1")];
|
||||
|
||||
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
|
||||
const exportProvider = new PascalVOCExportProvider(testProject, options);
|
||||
await exportProvider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
@@ -345,7 +345,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
|
||||
describe("Test Train Splits", () => {
|
||||
async function testTestTrainSplit(testTrainSplit: number): Promise<void> {
|
||||
const options: ITFPascalVOCExportProviderOptions = {
|
||||
const options: IPascalVOCExportProviderOptions = {
|
||||
assetState: ExportAssetState.Tagged,
|
||||
exportUnassigned: true,
|
||||
testTrainSplit,
|
||||
@@ -357,7 +357,7 @@ describe("TFPascalVOC Json Export Provider", () => {
|
||||
testProject.assets = _.keyBy(testAssets, (asset) => asset.id);
|
||||
testProject.tags = [MockFactory.createTestTag("1")];
|
||||
|
||||
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
|
||||
const exportProvider = new PascalVOCExportProvider(testProject, options);
|
||||
await exportProvider.export();
|
||||
|
||||
const storageProviderMock = LocalFileSystemProxy as any;
|
||||
@@ -1,11 +1,10 @@
|
||||
import _ from "lodash";
|
||||
import { ExportProvider } from "./exportProvider";
|
||||
import { IProject, IAssetMetadata, RegionType, ITag, IExportProviderOptions } from "../../models/applicationState";
|
||||
import { IProject, IAssetMetadata, ITag, IExportProviderOptions } from "../../models/applicationState";
|
||||
import Guard from "../../common/guard";
|
||||
import HtmlFileReader from "../../common/htmlFileReader";
|
||||
import { itemTemplate, annotationTemplate, objectTemplate } from "./tensorFlowPascalVOC/tensorFlowPascalVOCTemplates";
|
||||
import { itemTemplate, annotationTemplate, objectTemplate } from "./pascalVOC/pascalVOCTemplates";
|
||||
import { interpolate } from "../../common/strings";
|
||||
import { PlatformType } from "../../common/hostProcess";
|
||||
import os from "os";
|
||||
|
||||
interface IObjectInfo {
|
||||
@@ -23,9 +22,9 @@ interface IImageInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* Export options for TensorFlow Pascal VOC Export Provider
|
||||
* Export options for Pascal VOC Export Provider
|
||||
*/
|
||||
export interface ITFPascalVOCExportProviderOptions extends IExportProviderOptions {
|
||||
export interface IPascalVOCExportProviderOptions extends IExportProviderOptions {
|
||||
/** The test / train split ratio for exporting data */
|
||||
testTrainSplit?: number;
|
||||
/** Whether or not to include unassigned tags in exported data */
|
||||
@@ -33,19 +32,19 @@ export interface ITFPascalVOCExportProviderOptions extends IExportProviderOption
|
||||
}
|
||||
|
||||
/**
|
||||
* @name - TFPascalVOC Json Export Provider
|
||||
* @description - Exports a project into a single JSON file that include all configured assets
|
||||
* @name - PascalVOC Export Provider
|
||||
* @description - Exports a project into a Pascal VOC
|
||||
*/
|
||||
export class TFPascalVOCExportProvider extends ExportProvider<ITFPascalVOCExportProviderOptions> {
|
||||
export class PascalVOCExportProvider extends ExportProvider<IPascalVOCExportProviderOptions> {
|
||||
private imagesInfo = new Map<string, IImageInfo>();
|
||||
|
||||
constructor(project: IProject, options: ITFPascalVOCExportProviderOptions) {
|
||||
constructor(project: IProject, options: IPascalVOCExportProviderOptions) {
|
||||
super(project, options);
|
||||
Guard.null(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export project to TensorFlow PascalVOC
|
||||
* Export project to PascalVOC
|
||||
*/
|
||||
public async export(): Promise<void> {
|
||||
const allAssets = await this.getAssetsForExport();
|
||||
@@ -53,7 +52,7 @@ export class TFPascalVOCExportProvider extends ExportProvider<ITFPascalVOCExport
|
||||
exportObject.assets = _.keyBy(allAssets, (assetMetadata) => assetMetadata.asset.id);
|
||||
|
||||
// Create Export Folder
|
||||
const exportFolderName = `${this.project.name.replace(" ", "-")}-TFPascalVOC-export`;
|
||||
const exportFolderName = `${this.project.name.replace(/\s/g, "-")}-PascalVOC-export`;
|
||||
await this.storageProvider.createContainer(exportFolderName);
|
||||
|
||||
await this.exportImages(exportFolderName, allAssets);
|
||||
+1
-1
@@ -31,6 +31,6 @@ export const objectTemplate = "\
|
||||
<xmin>${xmin}</xmin>\n\
|
||||
<ymin>${ymin}</ymin>\n\
|
||||
<xmax>${xmax}</xmax>\n\
|
||||
<ymax>${xmin}</ymax>\n\
|
||||
<ymax>${ymax}</ymax>\n\
|
||||
</bndbox>\n\
|
||||
</object>";
|
||||
@@ -4,7 +4,7 @@ import { ExportProvider } from "./exportProvider";
|
||||
import { IProject, IAssetMetadata, IExportProviderOptions } from "../../models/applicationState";
|
||||
import Guard from "../../common/guard";
|
||||
import HtmlFileReader from "../../common/htmlFileReader";
|
||||
import { itemTemplate } from "./tensorFlowPascalVOC/tensorFlowPascalVOCTemplates";
|
||||
import { itemTemplate } from "./pascalVOC/pascalVOCTemplates";
|
||||
import { interpolate } from "../../common/strings";
|
||||
import { TFRecordsBuilder, FeatureType } from "./tensorFlowRecords/tensorFlowBuilder";
|
||||
|
||||
@@ -41,7 +41,7 @@ export class TFRecordsExportProvider extends ExportProvider {
|
||||
exportObject.assets = _.keyBy(allAssets, (assetMetadata) => assetMetadata.asset.id);
|
||||
|
||||
// Create Export Folder
|
||||
const exportFolderName = `${this.project.name.replace(" ", "-")}-TFRecords-export`;
|
||||
const exportFolderName = `${this.project.name.replace(/\s/g, "-")}-TFRecords-export`;
|
||||
await this.storageProvider.createContainer(exportFolderName);
|
||||
|
||||
await this.exportPBTXT(exportFolderName, this.project);
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
"includeImages": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"title": "${strings.export.providers.vottJson.properties.includeImages.title}",
|
||||
"description": "${strings.export.providers.vottJson.properties.includeImages.description}"
|
||||
"title": "${strings.export.providers.common.properties.includeImages.title}",
|
||||
"description": "${strings.export.providers.common.properties.includeImages.description}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { VottJsonExportProvider, IVottJsonExportProviderOptions } from "./vottJs
|
||||
import registerProviders from "../../registerProviders";
|
||||
import { ExportAssetState } from "./exportProvider";
|
||||
import { ExportProviderFactory } from "./exportProviderFactory";
|
||||
import { IProject, IAssetMetadata, AssetState, IExportProviderOptions } from "../../models/applicationState";
|
||||
import { IProject, IAssetMetadata, AssetState } from "../../models/applicationState";
|
||||
import MockFactory from "../../common/mockFactory";
|
||||
|
||||
jest.mock("../../services/assetService");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import _ from "lodash";
|
||||
import { ExportProvider } from "./exportProvider";
|
||||
import { IProject, IExportProviderOptions, IAssetMetadata } from "../../models/applicationState";
|
||||
import { IProject, IExportProviderOptions } from "../../models/applicationState";
|
||||
import Guard from "../../common/guard";
|
||||
import { constants } from "../../common/constants";
|
||||
import HtmlFileReader from "../../common/htmlFileReader";
|
||||
@@ -31,17 +31,9 @@ export class VottJsonExportProvider extends ExportProvider<IVottJsonExportProvid
|
||||
|
||||
if (this.options.includeImages) {
|
||||
await results.forEachAsync(async (assetMetadata) => {
|
||||
return new Promise<void>(async (resolve) => {
|
||||
const blob = await HtmlFileReader.getAssetBlob(assetMetadata.asset);
|
||||
const assetFilePath = `vott-json-export/${assetMetadata.asset.name}`;
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = async () => {
|
||||
const buffer = Buffer.from(fileReader.result as ArrayBuffer);
|
||||
await this.storageProvider.writeBinary(assetFilePath, buffer);
|
||||
resolve();
|
||||
};
|
||||
fileReader.readAsArrayBuffer(blob);
|
||||
});
|
||||
const arrayBuffer = await HtmlFileReader.getAssetArray(assetMetadata.asset);
|
||||
const assetFilePath = `vott-json-export/${assetMetadata.asset.name}`;
|
||||
await this.storageProvider.writeBinary(assetFilePath, Buffer.from(arrayBuffer));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +45,7 @@ export class VottJsonExportProvider extends ExportProvider<IVottJsonExportProvid
|
||||
delete exportObject.targetConnection;
|
||||
delete exportObject.exportFormat;
|
||||
|
||||
const fileName = `vott-json-export/${this.project.name.replace(" ", "-")}${constants.exportFileExtension}`;
|
||||
const fileName = `vott-json-export/${this.project.name.replace(/\s/g, "-")}${constants.exportFileExtension}`;
|
||||
await this.storageProvider.writeText(fileName, JSON.stringify(exportObject, null, 4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"title": "${strings.connections.providers.azureBlob.accountName.title}",
|
||||
"type": "string"
|
||||
},
|
||||
"accountKey": {
|
||||
"title": "${strings.connections.providers.azureBlob.accountKey.title}",
|
||||
"type": "string"
|
||||
},
|
||||
"containerName": {
|
||||
"title": "${strings.connections.providers.azureBlob.containerName.title}",
|
||||
"type": "string"
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { IStorageProvider } from "./storageProviderFactory";
|
||||
import { IAsset, AssetType, StorageType } from "../../models/applicationState";
|
||||
import { Aborter, AnonymousCredential, BlockBlobURL, ContainerURL,
|
||||
Credential, ServiceURL, StorageURL, TokenCredential, SharedKeyCredential } from "@azure/storage-blob";
|
||||
import { AssetType, IAsset, StorageType } from "../../models/applicationState";
|
||||
import { AssetService } from "../../services/assetService";
|
||||
import {
|
||||
TokenCredential, AnonymousCredential, ContainerURL,
|
||||
StorageURL, ServiceURL, Credential, Aborter, BlockBlobURL,
|
||||
} from "@azure/storage-blob";
|
||||
import { BlobDeleteResponse } from "@azure/storage-blob/typings/lib/generated/lib/models";
|
||||
import { IStorageProvider } from "./storageProviderFactory";
|
||||
|
||||
/**
|
||||
* Options for Azure Cloud Storage
|
||||
@@ -19,6 +16,7 @@ export interface IAzureCloudStorageOptions {
|
||||
accountName: string;
|
||||
containerName: string;
|
||||
createContainer: boolean;
|
||||
accountKey?: string;
|
||||
sas?: string;
|
||||
oauthToken?: string;
|
||||
}
|
||||
@@ -229,6 +227,8 @@ export class AzureBlobStorage implements IStorageProvider {
|
||||
private getCredential(): Credential {
|
||||
if (this.options.oauthToken) {
|
||||
return new TokenCredential(this.options.oauthToken);
|
||||
} else if (this.options.accountKey) {
|
||||
return new SharedKeyCredential(this.options.accountName, this.options.accountKey);
|
||||
} else {
|
||||
return new AnonymousCredential();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ export class ImageAsset extends React.Component<IAssetProps> {
|
||||
<img ref={this.image}
|
||||
src={this.props.asset.path}
|
||||
onLoad={this.onLoad}
|
||||
onError={this.props.onError} />);
|
||||
onError={this.props.onError}
|
||||
crossOrigin="anonymous" />);
|
||||
}
|
||||
|
||||
private onLoad = () => {
|
||||
|
||||
@@ -31,7 +31,8 @@ export class TFRecordAsset extends React.Component<IAssetProps, ITFRecordState>
|
||||
<img ref={this.image}
|
||||
src={this.state.tfRecordImage64}
|
||||
onLoad={this.onLoad}
|
||||
onError={this.onError} />
|
||||
onError={this.onError}
|
||||
crossOrigin="anonymous" />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ export class VideoAsset extends React.Component<IVideoAssetProps> {
|
||||
height="100%"
|
||||
autoPlay={autoPlay}
|
||||
src={videoPath}
|
||||
onError={this.props.onError}>
|
||||
onError={this.props.onError}
|
||||
crossOrigin="anonymous">
|
||||
<BigPlayButton position="center" />
|
||||
{autoPlay &&
|
||||
<ControlBar autoHide={false}>
|
||||
|
||||
@@ -25,20 +25,22 @@ export class ColorPicker extends React.Component<IColorPickerProps> {
|
||||
|
||||
private GithubPicker = () => {
|
||||
return (
|
||||
<GithubPicker
|
||||
color={{hex: this.props.color}}
|
||||
onChangeComplete={this.onChange}
|
||||
colors={this.props.colors}
|
||||
width={160}
|
||||
styles={{
|
||||
default: {
|
||||
card: {
|
||||
background: this.pickerBackground,
|
||||
<div className="color-picker">
|
||||
<GithubPicker
|
||||
color={{hex: this.props.color}}
|
||||
onChangeComplete={this.onChange}
|
||||
colors={this.props.colors}
|
||||
width={160}
|
||||
styles={{
|
||||
default: {
|
||||
card: {
|
||||
background: this.pickerBackground,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
triangle={"hide"}
|
||||
/>
|
||||
}}
|
||||
triangle={"hide"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from "react";
|
||||
import { mount, ReactWrapper } from "enzyme";
|
||||
import axios from "axios";
|
||||
import ExternalPicker, { IExternalPickerProps, IExternalPickerState } from "./externalPicker";
|
||||
import ExternalPicker, { IExternalPickerProps, IExternalPickerState, FilterOperator } from "./externalPicker";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
|
||||
describe("External Picker", () => {
|
||||
let wrapper: ReactWrapper<IExternalPickerProps, IExternalPickerState> = null;
|
||||
const onChangeHandler = jest.fn();
|
||||
const defaultProps = createProps({
|
||||
id: "my-custom-control",
|
||||
@@ -16,12 +15,13 @@ describe("External Picker", () => {
|
||||
formContext: {
|
||||
providerOptions: {
|
||||
apiKey: "",
|
||||
region: "",
|
||||
},
|
||||
},
|
||||
onChange: onChangeHandler,
|
||||
options: {
|
||||
method: "GET",
|
||||
url: "https://myserver/api",
|
||||
url: "https://${props.formContext.providerOptions.region}.server.com/api",
|
||||
keySelector: "${item.key}",
|
||||
valueSelector: "${item.value}",
|
||||
authHeaderName: "Authorization",
|
||||
@@ -48,32 +48,34 @@ describe("External Picker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent(defaultProps as IExternalPickerProps);
|
||||
});
|
||||
|
||||
it("Renders select element with default option", () => {
|
||||
const wrapper = createComponent(defaultProps);
|
||||
expect(wrapper.find("select").length).toEqual(1);
|
||||
expect(wrapper.find("option").length).toEqual(1);
|
||||
});
|
||||
|
||||
it("Does not bind external data if authorization is missing", () => {
|
||||
createComponent(defaultProps);
|
||||
expect(axios.request).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("Renders items bound from external data when formContext rebinds", async () => {
|
||||
const expectedApiKey = "ABC123";
|
||||
const expectedRegion = "southcentralus";
|
||||
|
||||
await MockFactory.flushUi(() => {
|
||||
wrapper.setProps({
|
||||
formContext: {
|
||||
providerOptions: {
|
||||
apiKey: expectedApiKey,
|
||||
},
|
||||
const props = {
|
||||
...defaultProps,
|
||||
formContext: {
|
||||
providerOptions: {
|
||||
apiKey: expectedApiKey,
|
||||
region: expectedRegion,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = createComponent(props);
|
||||
|
||||
await MockFactory.flushUi();
|
||||
wrapper.update();
|
||||
|
||||
const expectedHeaders = {};
|
||||
@@ -81,7 +83,7 @@ describe("External Picker", () => {
|
||||
|
||||
expect(axios.request).toBeCalledWith({
|
||||
method: defaultProps.options.method,
|
||||
url: defaultProps.options.url,
|
||||
url: `https://${expectedRegion}.server.com/api`,
|
||||
headers: expectedHeaders,
|
||||
});
|
||||
|
||||
@@ -93,19 +95,81 @@ describe("External Picker", () => {
|
||||
});
|
||||
|
||||
it("Calls onChange event handler on option selection", () => {
|
||||
wrapper.setProps({
|
||||
formContext: {},
|
||||
});
|
||||
const wrapper = createComponent(defaultProps);
|
||||
|
||||
wrapper.find("select").simulate("change", { target: { value: testResponse[0].key } });
|
||||
expect(onChangeHandler).toBeCalledWith(testResponse[0].key);
|
||||
});
|
||||
|
||||
function createProps(otherProps: any): IExternalPickerProps {
|
||||
it("Clears items when HTTP request fails", async () => {
|
||||
const requestMock = axios.request as jest.Mock;
|
||||
requestMock.mockImplementationOnce(() => Promise.reject({ status: 400 }));
|
||||
|
||||
const expectedApiKey = "ABC123";
|
||||
const expectedRegion = "southcentralus";
|
||||
|
||||
const props: IExternalPickerProps = {
|
||||
...otherProps,
|
||||
...defaultProps,
|
||||
formContext: {
|
||||
providerOptions: {
|
||||
apiKey: expectedApiKey,
|
||||
region: expectedRegion,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return props;
|
||||
const wrapper = createComponent(props);
|
||||
await MockFactory.flushUi();
|
||||
|
||||
expect(wrapper.state().items).toEqual([]);
|
||||
expect(onChangeHandler).toBeCalledWith(undefined);
|
||||
});
|
||||
|
||||
describe("Filters items", () => {
|
||||
it("Applies a filter to the item when defined", async () => {
|
||||
const requestMock = axios.request as jest.Mock;
|
||||
requestMock.mockImplementationOnce(() => Promise.resolve({
|
||||
data: [
|
||||
{ id: "1", name: "Object Detection 1", type: "ObjectDetection" },
|
||||
{ id: "2", name: "Object Detection 2", type: "ObjectDetection" },
|
||||
{ id: "3", name: "Classification 1", type: "Classification" },
|
||||
{ id: "4", name: "Classification 2", type: "Classification" },
|
||||
],
|
||||
status: 200,
|
||||
}));
|
||||
|
||||
const props: IExternalPickerProps = {
|
||||
...defaultProps,
|
||||
formContext: {
|
||||
providerOptions: {
|
||||
apiKey: "ABC123",
|
||||
region: "southcentralus",
|
||||
projectType: "Classification",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
props.options.keySelector = "${item.id}";
|
||||
props.options.valueSelector = "${item.name}";
|
||||
props.options.filter = {
|
||||
left: "${item.type}",
|
||||
right: "${props.formContext.providerOptions.projectType}",
|
||||
operator: FilterOperator.Equals,
|
||||
};
|
||||
|
||||
const wrapper = createComponent(props);
|
||||
await MockFactory.flushUi();
|
||||
|
||||
expect(wrapper.state().items).toEqual([
|
||||
{ key: "3", value: "Classification 1" },
|
||||
{ key: "4", value: "Classification 2" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function createProps(otherProps: any): IExternalPickerProps {
|
||||
return {
|
||||
...otherProps,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,6 +24,19 @@ export interface IExternalPickerUiOptions {
|
||||
valueSelector: string;
|
||||
authHeaderName?: string;
|
||||
authHeaderValue?: string;
|
||||
filter?: IExternalPickerFilter;
|
||||
}
|
||||
|
||||
export interface IExternalPickerFilter {
|
||||
left: string;
|
||||
right: string;
|
||||
operator: FilterOperator;
|
||||
}
|
||||
|
||||
export enum FilterOperator {
|
||||
Equals = "eq",
|
||||
GreaterThan = "gt",
|
||||
LessThan = "lt",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,15 +59,9 @@ export interface IExternalPickerState {
|
||||
* Dropdown that provides options from an external HTTP source
|
||||
*/
|
||||
export default class ExternalPicker extends React.Component<IExternalPickerProps, any> {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
public state: IExternalPickerState = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
@@ -78,12 +85,12 @@ export default class ExternalPicker extends React.Component<IExternalPickerProps
|
||||
}
|
||||
}
|
||||
|
||||
private onChange(e: SyntheticEvent) {
|
||||
private onChange = (e: SyntheticEvent) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
this.props.onChange(target.value === "" ? undefined : target.value);
|
||||
}
|
||||
|
||||
private async bindExternalData() {
|
||||
private bindExternalData = async (): Promise<void> => {
|
||||
const uiOptions = this.props.options;
|
||||
const customHeaders: any = {};
|
||||
const authHeaderValue = interpolate(uiOptions.authHeaderValue, {
|
||||
@@ -98,24 +105,52 @@ export default class ExternalPicker extends React.Component<IExternalPickerProps
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
method: uiOptions.method,
|
||||
url: uiOptions.url,
|
||||
url: interpolate(uiOptions.url, { props: this.props }),
|
||||
headers: customHeaders,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.request(config);
|
||||
const items: IKeyValuePair[] = response.data.map((item) => {
|
||||
|
||||
let rawItems: any[] = response.data;
|
||||
|
||||
// Optionally filter results if a filter has been defined
|
||||
if (uiOptions.filter) {
|
||||
rawItems = rawItems.filter((item) => this.filterPredicate(item, uiOptions.filter));
|
||||
}
|
||||
|
||||
const items: IKeyValuePair[] = rawItems.map((item) => {
|
||||
return {
|
||||
key: interpolate(uiOptions.keySelector, { item }),
|
||||
value: interpolate(uiOptions.valueSelector, { item }),
|
||||
};
|
||||
});
|
||||
|
||||
this.setState({
|
||||
items,
|
||||
});
|
||||
this.setState({ items });
|
||||
} catch (e) {
|
||||
return;
|
||||
this.setState({ items: [] });
|
||||
this.props.onChange(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the specified item will return as part of the filter
|
||||
* @param item The item to evaluate
|
||||
* @param filter The filter expression to evaluate against
|
||||
*/
|
||||
private filterPredicate(item: any, filter: IExternalPickerFilter): boolean {
|
||||
const left = interpolate(filter.left, { item, props: this.props });
|
||||
const right = interpolate(filter.right, { item, props: this.props });
|
||||
|
||||
switch (filter.operator) {
|
||||
case FilterOperator.Equals:
|
||||
return left === right;
|
||||
case FilterOperator.GreaterThan:
|
||||
return left > right;
|
||||
case FilterOperator.LessThan:
|
||||
return left < right;
|
||||
default:
|
||||
throw new Error("Invalid filter operator");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class ProtectedInput extends React.Component<IProtectedInputProps, IProte
|
||||
|
||||
this.state = {
|
||||
showKey: false,
|
||||
value: this.props.value,
|
||||
value: this.props.value || "",
|
||||
};
|
||||
|
||||
this.toggleKeyVisibility = this.toggleKeyVisibility.bind(this);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TagInput, ITagInputProps, ITagInputState } from "./tagInput";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
import { ITag } from "../../../../models/applicationState";
|
||||
import TagInputItem, { ITagInputItemProps } from "./tagInputItem";
|
||||
import { ColorPicker } from "../colorPicker";
|
||||
|
||||
describe("Tag Input Component", () => {
|
||||
|
||||
@@ -40,6 +41,31 @@ describe("Tag Input Component", () => {
|
||||
expect(props.onCtrlTagClick).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("Edits tag name when alt clicked", () => {
|
||||
const props = createProps();
|
||||
const wrapper = createComponent(props);
|
||||
wrapper.find("div.tag-name-container").first().simulate("click", { altKey: true } );
|
||||
expect(wrapper.state().editingTag).toEqual(props.tags[0]);
|
||||
expect(wrapper.exists("input.tag-name-editor")).toBe(true);
|
||||
});
|
||||
|
||||
it("Edits tag color when alt clicked", () => {
|
||||
const props = createProps();
|
||||
const wrapper = createComponent(props);
|
||||
expect(wrapper.state().clickedColor).toBe(false);
|
||||
expect(wrapper.exists("div.color-picker")).toBe(false);
|
||||
wrapper.find("div.tag-color").first().simulate("click", { altKey: true } );
|
||||
expect(wrapper.state().clickedColor).toBe(true);
|
||||
expect(wrapper.state().showColorPicker).toBe(true);
|
||||
expect(wrapper.state().editingTag).toEqual(props.tags[0]);
|
||||
expect(wrapper.exists("div.color-picker")).toBe(true);
|
||||
// Get color picker and call onEditColor function
|
||||
const picker = wrapper.find(ColorPicker).instance() as ColorPicker;
|
||||
picker.props.onEditColor("#000000");
|
||||
expect(props.onChange).toBeCalled();
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Calls onClick handler when clicking text", () => {
|
||||
const props: ITagInputProps = createProps();
|
||||
const wrapper = createComponent(props);
|
||||
@@ -101,6 +127,34 @@ describe("Tag Input Component", () => {
|
||||
expect(wrapper.state().searchTags).toBe(true);
|
||||
});
|
||||
|
||||
it("Add tag box closed with escape key", () => {
|
||||
const wrapper = createComponent();
|
||||
expect(wrapper.exists(".tag-input-box")).toBe(false);
|
||||
expect(wrapper.state().addTags).toBeFalsy();
|
||||
wrapper.find("div.tag-input-toolbar-item.plus").simulate("click");
|
||||
expect(wrapper.exists(".tag-input-box")).toBe(true);
|
||||
expect(wrapper.state().addTags).toBe(true);
|
||||
|
||||
wrapper.find(".tag-input-box").simulate("keydown", { key: "Escape" });
|
||||
expect(wrapper.exists(".tag-input-box")).toBe(false);
|
||||
expect(wrapper.state().addTags).toBe(false);
|
||||
});
|
||||
|
||||
it("Tag search box closed with escape key", async () => {
|
||||
const wrapper = createComponent();
|
||||
expect(wrapper.exists(".tag-search-box")).toBe(false);
|
||||
expect(wrapper.state().searchTags).toBeFalsy();
|
||||
wrapper.find("div.tag-input-toolbar-item.search").simulate("click");
|
||||
expect(wrapper.exists(".tag-search-box")).toBe(true);
|
||||
expect(wrapper.state().searchTags).toBe(true);
|
||||
|
||||
wrapper.find(".tag-search-box").simulate("keydown", { key: "Escape" });
|
||||
await MockFactory.flushUi();
|
||||
expect(wrapper.state().searchTags).toBe(false);
|
||||
|
||||
expect(wrapper.exists(".tag-search-box")).toBe(false);
|
||||
});
|
||||
|
||||
it("Tag can be locked from toolbar", () => {
|
||||
const tags = MockFactory.createTestTags();
|
||||
const props = createProps(tags);
|
||||
@@ -110,7 +164,7 @@ describe("Tag Input Component", () => {
|
||||
expect(props.onLockedTagsChange).toBeCalledWith([tags[0].name]);
|
||||
});
|
||||
|
||||
it("Tag can be edited from toolbar", () => {
|
||||
it("Tag name can be edited from toolbar", () => {
|
||||
const tags = MockFactory.createTestTags();
|
||||
const props = createProps(tags);
|
||||
const wrapper = createComponent(props);
|
||||
@@ -120,6 +174,25 @@ describe("Tag Input Component", () => {
|
||||
expect(wrapper.exists("input.tag-name-editor")).toBe(true);
|
||||
});
|
||||
|
||||
it("Tag color can be edited from toolbar", () => {
|
||||
const tags = MockFactory.createTestTags();
|
||||
const props = createProps(tags);
|
||||
const wrapper = createComponent(props);
|
||||
expect(wrapper.state().clickedColor).toBe(false);
|
||||
expect(wrapper.exists("div.color-picker")).toBe(false);
|
||||
wrapper.find("div.tag-color").first().simulate("click");
|
||||
expect(wrapper.state().clickedColor).toBe(true);
|
||||
wrapper.find("div.tag-input-toolbar-item.edit").simulate("click");
|
||||
expect(wrapper.state().showColorPicker).toBe(true);
|
||||
expect(wrapper.state().editingTag).toEqual(tags[0]);
|
||||
expect(wrapper.exists("div.color-picker")).toBe(true);
|
||||
// Get color picker and call onEditColor function
|
||||
const picker = wrapper.find(ColorPicker).instance() as ColorPicker;
|
||||
picker.props.onEditColor("#000000");
|
||||
expect(props.onChange).toBeCalled();
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Tag can be moved up from toolbar", () => {
|
||||
const tags = MockFactory.createTestTags();
|
||||
const lastTag = tags[tags.length - 1];
|
||||
@@ -169,6 +242,16 @@ describe("Tag Input Component", () => {
|
||||
expect(props.onChange).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("Does not try to add tag with same name as existing tag", () => {
|
||||
const props: ITagInputProps = {
|
||||
...createProps(),
|
||||
showTagInputBox: true,
|
||||
};
|
||||
const wrapper = createComponent(props);
|
||||
wrapper.find(".tag-input-box").simulate("keydown", { key: "Enter", target: { value: props.tags[0].name } });
|
||||
expect(props.onChange).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("Selects a tag", () => {
|
||||
const tags = MockFactory.createTestTags();
|
||||
const onChange = jest.fn();
|
||||
@@ -230,6 +313,54 @@ describe("Tag Input Component", () => {
|
||||
expect(onChange).toBeCalledWith(expectedTags);
|
||||
});
|
||||
|
||||
it("Does not edit tag name with empty string", () => {
|
||||
const tags = MockFactory.createTestTags();
|
||||
const onChange = jest.fn();
|
||||
const onTagNameChange = jest.fn();
|
||||
const props = {
|
||||
...createProps(tags, onChange),
|
||||
onTagNameChange,
|
||||
};
|
||||
const wrapper = createComponent(props);
|
||||
wrapper.find(".tag-content").first().simulate("click");
|
||||
wrapper.find("i.tag-input-toolbar-icon.fas.fa-edit").simulate("click");
|
||||
wrapper.find("input.tag-name-editor").simulate("keydown", { key: "Enter", target: { value: "" } });
|
||||
expect(wrapper.state().tags).toEqual(tags);
|
||||
expect(onChange).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("Does not call onChange when edited tag name is the same", () => {
|
||||
const tags = MockFactory.createTestTags();
|
||||
const onChange = jest.fn();
|
||||
const onTagNameChange = jest.fn();
|
||||
const props = {
|
||||
...createProps(tags, onChange),
|
||||
onTagNameChange,
|
||||
};
|
||||
const wrapper = createComponent(props);
|
||||
wrapper.find(".tag-content").first().simulate("click");
|
||||
wrapper.find("i.tag-input-toolbar-icon.fas.fa-edit").simulate("click");
|
||||
wrapper.find("input.tag-name-editor").simulate("keydown", { key: "Enter", target: { value: tags[0].name } });
|
||||
expect(wrapper.state().tags).toEqual(tags);
|
||||
expect(onChange).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("Does not change tag name to name of other existing tag", () => {
|
||||
const tags = MockFactory.createTestTags();
|
||||
const onChange = jest.fn();
|
||||
const onTagNameChange = jest.fn();
|
||||
const props = {
|
||||
...createProps(tags, onChange),
|
||||
onTagNameChange,
|
||||
};
|
||||
const wrapper = createComponent(props);
|
||||
wrapper.find(".tag-content").first().simulate("click");
|
||||
wrapper.find("i.tag-input-toolbar-icon.fas.fa-edit").simulate("click");
|
||||
wrapper.find("input.tag-name-editor").simulate("keydown", { key: "Enter", target: { value: tags[1].name } });
|
||||
expect(wrapper.state().tags).toEqual(tags);
|
||||
expect(onChange).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("Reorders a tag", () => {
|
||||
const tags = MockFactory.createTestTags();
|
||||
const onChange = jest.fn();
|
||||
@@ -247,7 +378,20 @@ describe("Tag Input Component", () => {
|
||||
expect(wrapper.state().tags.indexOf(firstTag)).toEqual(0);
|
||||
});
|
||||
|
||||
it("set's applied tags when selected regions are available", () => {
|
||||
it("Searches for a tag", () => {
|
||||
const props: ITagInputProps = {
|
||||
...createProps(),
|
||||
showSearchBox: true,
|
||||
};
|
||||
const wrapper = createComponent(props);
|
||||
expect(wrapper.find(".tag-item-block").length).toBeGreaterThan(1);
|
||||
wrapper.find(".tag-search-box").simulate("change", { target: { value: "1" } });
|
||||
expect(wrapper.state().searchQuery).toEqual("1");
|
||||
expect(wrapper.find(".tag-item-block")).toHaveLength(1);
|
||||
expect(wrapper.find(".tag-name-body").first().text()).toEqual("Tag 1");
|
||||
});
|
||||
|
||||
it("sets applied tags when selected regions are available", () => {
|
||||
const tags = MockFactory.createTestTags();
|
||||
const onChange = jest.fn();
|
||||
const props = createProps(tags, onChange);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { KeyboardEvent } from "react";
|
||||
import React, { KeyboardEvent, RefObject } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import Align from "rc-align";
|
||||
import { randomIntInRange } from "../../../../common/utils";
|
||||
@@ -31,9 +31,9 @@ export interface ITagInputProps {
|
||||
/** Function to call on clicking individual tag while holding CTRL key */
|
||||
onCtrlTagClick?: (tag: ITag) => void;
|
||||
/** Function to call when tag is renamed */
|
||||
onTagRenamed?: (oldTag: string, newTag: string) => void;
|
||||
onTagRenamed?: (tagName: string, newTagName: string) => void;
|
||||
/** Function to call when tag is deleted */
|
||||
onTagDeleted?: (tag: ITag) => void;
|
||||
onTagDeleted?: (tagName: string) => void;
|
||||
/** Always show tag input box */
|
||||
showTagInputBox?: boolean;
|
||||
/** Always show tag search box */
|
||||
@@ -72,7 +72,7 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
portalElement: defaultDOMNode(),
|
||||
};
|
||||
|
||||
private tagItemRefs: { [id: string]: TagInputItem } = {};
|
||||
private tagItemRefs: Map<string, TagInputItem> = new Map<string, TagInputItem>();
|
||||
private portalDiv = document.createElement("div");
|
||||
|
||||
public render() {
|
||||
@@ -98,6 +98,7 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
this.state.searchTags &&
|
||||
<div className="tag-input-text-input-row search-input">
|
||||
<input
|
||||
className="tag-search-box"
|
||||
type="text"
|
||||
onKeyDown={this.onSearchKeyDown}
|
||||
onChange={(e) => this.setState({ searchQuery: e.target.value })}
|
||||
@@ -109,7 +110,7 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
}
|
||||
{this.getColorPickerPortal()}
|
||||
<div className="tag-input-items">
|
||||
{this.getTagItems()}
|
||||
{this.renderTagItems()}
|
||||
</div>
|
||||
{
|
||||
this.state.addTags &&
|
||||
@@ -154,21 +155,17 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
}
|
||||
}
|
||||
|
||||
private getTagNode = (tag: ITag) => {
|
||||
if (!tag) {
|
||||
return defaultDOMNode();
|
||||
}
|
||||
return ReactDOM.findDOMNode(this.tagItemRefs[tag.name]) as Element;
|
||||
private getTagNode = (tag: ITag): Element => {
|
||||
const itemRef = tag ? this.tagItemRefs.get(tag.name) : null;
|
||||
return (itemRef ? ReactDOM.findDOMNode(itemRef) : defaultDOMNode()) as Element;
|
||||
}
|
||||
|
||||
private onEditTag = (tag: ITag) => {
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
const { editingTag } = this.state;
|
||||
const newEditingTag = (editingTag && editingTag.name === tag.name) ? null : tag;
|
||||
this.setState({
|
||||
editingTag: newEditingTag,
|
||||
editingTagNode: this.getTagNode(newEditingTag),
|
||||
});
|
||||
if (this.state.clickedColor) {
|
||||
this.setState({
|
||||
@@ -219,20 +216,25 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
}, () => this.props.onChange(tags));
|
||||
}
|
||||
|
||||
private updateTag = (oldTag: ITag, newTag: ITag) => {
|
||||
if (oldTag === newTag) {
|
||||
private updateTag = (tag: ITag, newTag: ITag) => {
|
||||
if (tag.name === newTag.name && tag.color === newTag.color) {
|
||||
return;
|
||||
}
|
||||
if (!newTag.name.length) {
|
||||
toast.warn(strings.tags.warnings.emptyName);
|
||||
return;
|
||||
}
|
||||
if (newTag.name !== oldTag.name && this.state.tags.some((t) => t.name === newTag.name)) {
|
||||
const nameChange = tag.name !== newTag.name;
|
||||
if (nameChange && this.state.tags.some((t) => t.name === newTag.name)) {
|
||||
toast.warn(strings.tags.warnings.existingName);
|
||||
return;
|
||||
}
|
||||
if (nameChange && this.props.onTagRenamed) {
|
||||
this.props.onTagRenamed(tag.name, newTag.name);
|
||||
return;
|
||||
}
|
||||
const tags = this.state.tags.map((t) => {
|
||||
return (t.name === oldTag.name) ? newTag : t;
|
||||
return (t.name === tag.name) ? newTag : t;
|
||||
});
|
||||
this.setState({
|
||||
tags,
|
||||
@@ -293,12 +295,15 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
return this.state.editingTagNode || document;
|
||||
}
|
||||
|
||||
private getTagItems = () => {
|
||||
let props = this.getTagItemProps();
|
||||
private renderTagItems = () => {
|
||||
let props = this.createTagItemProps();
|
||||
const query = this.state.searchQuery;
|
||||
this.tagItemRefs.clear();
|
||||
|
||||
if (query.length) {
|
||||
props = props.filter((prop) => prop.tag.name.toLowerCase().includes(query.toLowerCase()));
|
||||
}
|
||||
|
||||
return props.map((prop) =>
|
||||
<TagInputItem
|
||||
key={prop.tag.name}
|
||||
@@ -307,17 +312,17 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
/>);
|
||||
}
|
||||
|
||||
private setTagItemRef = (item, tag) => {
|
||||
if (item) {
|
||||
this.tagItemRefs[tag.name] = item;
|
||||
}
|
||||
private setTagItemRef = (item: TagInputItem, tag: ITag) => {
|
||||
this.tagItemRefs.set(tag.name, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
private getTagItemProps = (): ITagInputItemProps[] => {
|
||||
private createTagItemProps = (): ITagInputItemProps[] => {
|
||||
const tags = this.state.tags;
|
||||
const selectedRegionTagSet = this.getSelectedRegionTagSet();
|
||||
return tags.map((tag) => {
|
||||
const item: ITagInputItemProps = {
|
||||
|
||||
return tags.map((tag) => (
|
||||
{
|
||||
tag,
|
||||
index: tags.findIndex((t) => t.name === tag.name),
|
||||
isLocked: this.props.lockedTags && this.props.lockedTags.findIndex((t) => t === tag.name) > -1,
|
||||
@@ -326,9 +331,8 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
appliedToSelectedRegions: selectedRegionTagSet.has(tag.name),
|
||||
onClick: this.handleClick,
|
||||
onChange: this.updateTag,
|
||||
};
|
||||
return item;
|
||||
});
|
||||
} as ITagInputItemProps
|
||||
));
|
||||
}
|
||||
|
||||
private getSelectedRegionTagSet = (): Set<string> => {
|
||||
@@ -346,6 +350,7 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
private onAltClick = (tag: ITag, clickedColor: boolean) => {
|
||||
const { editingTag } = this.state;
|
||||
const newEditingTag = editingTag && editingTag.name === tag.name ? null : tag;
|
||||
|
||||
this.setState({
|
||||
editingTag: newEditingTag,
|
||||
editingTagNode: this.getTagNode(newEditingTag),
|
||||
@@ -355,16 +360,16 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
}
|
||||
|
||||
private handleClick = (tag: ITag, props: ITagClickProps) => {
|
||||
// Lock tags
|
||||
if (props.ctrlKey && this.props.onCtrlTagClick) {
|
||||
this.props.onCtrlTagClick(tag);
|
||||
this.setState({ clickedColor: props.clickedColor });
|
||||
} else if (props.altKey) {
|
||||
} else if (props.altKey) { // Edit tag
|
||||
this.onAltClick(tag, props.clickedColor);
|
||||
} else {
|
||||
} else { // Select tag
|
||||
const { editingTag, selectedTag } = this.state;
|
||||
const inEditMode = editingTag && tag.name === editingTag.name;
|
||||
const alreadySelected = selectedTag && selectedTag.name === tag.name;
|
||||
|
||||
const newEditingTag = inEditMode ? null : editingTag;
|
||||
|
||||
this.setState({
|
||||
@@ -389,12 +394,19 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
if (this.props.onTagDeleted) {
|
||||
this.props.onTagDeleted(tag.name);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.state.tags.indexOf(tag);
|
||||
const tags = this.state.tags.filter((t) => t.name !== tag.name);
|
||||
|
||||
this.setState({
|
||||
tags,
|
||||
selectedTag: this.getNewSelectedTag(tags, index),
|
||||
}, () => this.props.onChange(tags));
|
||||
|
||||
if (this.props.lockedTags.find((l) => l === tag.name)) {
|
||||
this.props.onLockedTagsChange(
|
||||
this.props.lockedTags.filter((lockedTag) => lockedTag !== tag.name),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modelPathType": {
|
||||
"type": "string",
|
||||
"title": "${strings.activeLearning.form.properties.modelPathType.title}",
|
||||
"description": "${strings.activeLearning.form.properties.modelPathType.description}",
|
||||
"enum": [
|
||||
"coco",
|
||||
"file",
|
||||
"url"
|
||||
],
|
||||
"default": "coco",
|
||||
"enumNames": [
|
||||
"${strings.activeLearning.form.properties.modelPathType.options.preTrained}",
|
||||
"${strings.activeLearning.form.properties.modelPathType.options.customFilePath}",
|
||||
"${strings.activeLearning.form.properties.modelPathType.options.customWebUrl}"
|
||||
]
|
||||
},
|
||||
"autoDetect": {
|
||||
"title": "${strings.activeLearning.form.properties.autoDetect.title}",
|
||||
"description": "${strings.activeLearning.form.properties.autoDetect.description}",
|
||||
"type": "boolean"
|
||||
},
|
||||
"predictTag": {
|
||||
"title": " ${strings.activeLearning.form.properties.predictTag.title}",
|
||||
"description": "${strings.activeLearning.form.properties.predictTag.description}",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"modelPathType": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"modelPathType": {
|
||||
"enum": [
|
||||
"coco"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"modelPath"
|
||||
],
|
||||
"properties": {
|
||||
"modelPathType": {
|
||||
"enum": [
|
||||
"file"
|
||||
]
|
||||
},
|
||||
"modelPath": {
|
||||
"title": "${strings.activeLearning.form.properties.modelPath.title}",
|
||||
"description": "${strings.activeLearning.form.properties.modelPath.description}",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"modelUrl"
|
||||
],
|
||||
"properties": {
|
||||
"modelPathType": {
|
||||
"enum": [
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"modelUrl": {
|
||||
"title": "${strings.activeLearning.form.properties.modelUrl.title}",
|
||||
"description": "${strings.activeLearning.form.properties.modelUrl.description}",
|
||||
"default": "http://",
|
||||
"pattern": "^https?\\\\://[a-zA-Z0-9\\\\-\\\\.]+\\\\.[a-zA-Z]{2,3}(/\\\\S*)?$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
import { IActiveLearningFormProps, ActiveLearningForm, IActiveLearningFormState } from "./activeLearningForm";
|
||||
import { ReactWrapper, mount } from "enzyme";
|
||||
import { ModelPathType, IActiveLearningSettings } from "../../../../models/applicationState";
|
||||
import Form from "react-jsonschema-form";
|
||||
|
||||
describe("Active Learning Form", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onSubmitHandler = jest.fn();
|
||||
const onCancelHandler = jest.fn();
|
||||
const defaultProps: IActiveLearningFormProps = {
|
||||
settings: {
|
||||
modelPathType: ModelPathType.Coco,
|
||||
modelPath: null,
|
||||
modelUrl: null,
|
||||
autoDetect: false,
|
||||
predictTag: true,
|
||||
},
|
||||
onChange: onChangeHandler,
|
||||
onSubmit: onSubmitHandler,
|
||||
onCancel: onCancelHandler,
|
||||
};
|
||||
|
||||
function createComponent(props?: IActiveLearningFormProps)
|
||||
: ReactWrapper<IActiveLearningFormProps, IActiveLearningFormState> {
|
||||
props = props || defaultProps;
|
||||
return mount(<ActiveLearningForm {...props} />);
|
||||
}
|
||||
|
||||
it("renders a dynamic json schema form with default props", () => {
|
||||
const wrapper = createComponent();
|
||||
expect(wrapper.find(Form).exists()).toBe(true);
|
||||
expect(wrapper.state().formData).toEqual(defaultProps.settings);
|
||||
});
|
||||
|
||||
it("sets formData state when loaded with different props", () => {
|
||||
const props: IActiveLearningFormProps = {
|
||||
...defaultProps,
|
||||
settings: {
|
||||
modelPathType: ModelPathType.Url,
|
||||
modelUrl: "https://myserver.com/myModel",
|
||||
autoDetect: true,
|
||||
predictTag: true,
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = createComponent(props);
|
||||
expect(wrapper.state().formData).toEqual(props.settings);
|
||||
});
|
||||
|
||||
it("updates form data when the props change", () => {
|
||||
const wrapper = createComponent();
|
||||
|
||||
const newSettings: IActiveLearningSettings = {
|
||||
modelPathType: ModelPathType.Url,
|
||||
modelUrl: "https://myserver.com/myModel",
|
||||
autoDetect: true,
|
||||
predictTag: true,
|
||||
};
|
||||
|
||||
wrapper.setProps({ settings: newSettings });
|
||||
expect(wrapper.state().formData).toEqual(newSettings);
|
||||
});
|
||||
|
||||
it("sets formData state when form changes", () => {
|
||||
const wrapper = createComponent();
|
||||
const formData: IActiveLearningSettings = {
|
||||
modelPathType: ModelPathType.Url,
|
||||
modelUrl: "https://myserver.com/myModel",
|
||||
autoDetect: true,
|
||||
predictTag: true,
|
||||
};
|
||||
|
||||
// Set type to URL
|
||||
wrapper.find(Form).props().onChange({ formData: { modelPathType: ModelPathType.Url } });
|
||||
// Set the remaining settings
|
||||
wrapper.find(Form).props().onChange({ formData });
|
||||
expect(wrapper.state().formData).toEqual(formData);
|
||||
expect(onChangeHandler).toBeCalledWith(formData);
|
||||
});
|
||||
|
||||
it("submits form data to the registered submit handler", () => {
|
||||
const wrapper = createComponent();
|
||||
wrapper.find(Form).props().onSubmit({ formData: defaultProps.settings });
|
||||
|
||||
expect(onSubmitHandler).toBeCalledWith(defaultProps.settings);
|
||||
});
|
||||
|
||||
it("raises the cancel event and called registered handler", () => {
|
||||
const wrapper = createComponent();
|
||||
wrapper.find(".btn-cancel").simulate("click");
|
||||
|
||||
expect(onCancelHandler).toBeCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from "react";
|
||||
import Form, { ISubmitEvent, IChangeEvent, Widget } from "react-jsonschema-form";
|
||||
import { IActiveLearningSettings, ModelPathType } from "../../../../models/applicationState";
|
||||
import { strings, addLocValues } from "../../../../common/strings";
|
||||
import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
|
||||
import LocalFolderPicker from "../../common/localFolderPicker/localFolderPicker";
|
||||
import { CustomWidget } from "../../common/customField/customField";
|
||||
import Checkbox from "rc-checkbox";
|
||||
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const formSchema = addLocValues(require("./activeLearningForm.json"));
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const uiSchema = addLocValues(require("./activeLearningForm.ui.json"));
|
||||
|
||||
export interface IActiveLearningFormProps extends React.Props<ActiveLearningForm> {
|
||||
settings: IActiveLearningSettings;
|
||||
onSubmit: (settings: IActiveLearningSettings) => void;
|
||||
onChange?: (settings: IActiveLearningSettings) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export interface IActiveLearningFormState {
|
||||
classNames: string[];
|
||||
formData: IActiveLearningSettings;
|
||||
uiSchema: any;
|
||||
formSchema: any;
|
||||
}
|
||||
|
||||
export class ActiveLearningForm extends React.Component<IActiveLearningFormProps, IActiveLearningFormState> {
|
||||
public state: IActiveLearningFormState = {
|
||||
classNames: ["needs-validation"],
|
||||
uiSchema: { ...uiSchema },
|
||||
formSchema: { ...formSchema },
|
||||
formData: {
|
||||
...this.props.settings,
|
||||
},
|
||||
};
|
||||
|
||||
private widgets = {
|
||||
localFolderPicker: (LocalFolderPicker as any) as Widget,
|
||||
checkbox: CustomWidget(Checkbox, (props) => ({
|
||||
checked: props.value,
|
||||
onChange: (value) => props.onChange(value.target.checked),
|
||||
disabled: props.disabled,
|
||||
})),
|
||||
};
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IActiveLearningFormProps>) {
|
||||
if (this.props.settings !== prevProps.settings) {
|
||||
this.setState({ formData: this.props.settings });
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<Form
|
||||
className={this.state.classNames.join(" ")}
|
||||
showErrorList={false}
|
||||
liveValidate={true}
|
||||
noHtml5Validate={true}
|
||||
FieldTemplate={CustomFieldTemplate}
|
||||
widgets={this.widgets}
|
||||
schema={this.state.formSchema}
|
||||
uiSchema={this.state.uiSchema}
|
||||
formData={this.state.formData}
|
||||
onChange={this.onFormChange}
|
||||
onSubmit={this.onFormSubmit}>
|
||||
<div>
|
||||
<button className="btn btn-success mr-1" type="submit">{strings.projectSettings.save}</button>
|
||||
<button className="btn btn-secondary btn-cancel"
|
||||
type="button"
|
||||
onClick={this.onFormCancel}>{strings.common.cancel}</button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
private onFormChange = (changeEvent: IChangeEvent<IActiveLearningSettings>): void => {
|
||||
let updatedSettings = changeEvent.formData;
|
||||
|
||||
if (changeEvent.formData.modelPathType !== this.state.formData.modelPathType) {
|
||||
updatedSettings = {
|
||||
...changeEvent.formData,
|
||||
modelPath: null,
|
||||
modelUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
this.setState({
|
||||
formData: updatedSettings,
|
||||
}, () => {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(updatedSettings);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private onFormSubmit = (args: ISubmitEvent<IActiveLearningSettings>): void => {
|
||||
const settings: IActiveLearningSettings = {
|
||||
...args.formData,
|
||||
};
|
||||
|
||||
this.setState({ formData: settings });
|
||||
this.props.onSubmit(settings);
|
||||
}
|
||||
|
||||
private onFormCancel = (): void => {
|
||||
if (this.props.onCancel) {
|
||||
this.props.onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"modelPath": {
|
||||
"ui:widget": "localFolderPicker"
|
||||
},
|
||||
"predictTag": {
|
||||
"ui:widget": "checkbox"
|
||||
},
|
||||
"autoDetect": {
|
||||
"ui:widget": "checkbox"
|
||||
},
|
||||
"ui:order": [
|
||||
"modelPathType",
|
||||
"*",
|
||||
"predictTag",
|
||||
"autoDetect"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from "react";
|
||||
import ActiveLearningPage, { IActiveLearningPageProps, IActiveLearningPageState } from "./activeLearningPage";
|
||||
import { ReactWrapper, mount } from "enzyme";
|
||||
import { Provider } from "react-redux";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
import createReduxStore from "../../../../redux/store/store";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
import { ActiveLearningForm } from "./activeLearningForm";
|
||||
import { IActiveLearningSettings, ModelPathType } from "../../../../models/applicationState";
|
||||
jest.mock("../../../../services/projectService");
|
||||
import ProjectService from "../../../../services/projectService";
|
||||
import { toast } from "react-toastify";
|
||||
import { strings } from "../../../../common/strings";
|
||||
|
||||
describe("Active Learning Page", () => {
|
||||
function createComponent(store, props: IActiveLearningPageProps): ReactWrapper {
|
||||
return mount(
|
||||
<Provider store={store}>
|
||||
<Router>
|
||||
<ActiveLearningPage {...props} />
|
||||
</Router>
|
||||
</Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
toast.success = jest.fn(() => 2);
|
||||
});
|
||||
|
||||
it("renders and loads settings from props", () => {
|
||||
const testProject = MockFactory.createTestProject("TestProject");
|
||||
const store = createReduxStore(MockFactory.initialState({
|
||||
currentProject: testProject,
|
||||
}));
|
||||
|
||||
const props = MockFactory.activeLearningProps();
|
||||
const wrapper = createComponent(store, props);
|
||||
|
||||
const activeLearningPage = wrapper
|
||||
.find(ActiveLearningPage)
|
||||
.childAt(0) as ReactWrapper<IActiveLearningPageProps, IActiveLearningPageState>;
|
||||
|
||||
expect(activeLearningPage.state().settings).toEqual(testProject.activeLearningSettings);
|
||||
expect(wrapper.find(ActiveLearningForm).props().settings).toEqual(testProject.activeLearningSettings);
|
||||
});
|
||||
|
||||
it("updates active learning settings if project changes", () => {
|
||||
const store = createReduxStore(MockFactory.initialState());
|
||||
const props = MockFactory.activeLearningProps();
|
||||
|
||||
const testProject = props.recentProjects[0];
|
||||
const wrapper = createComponent(store, props);
|
||||
|
||||
const activeLearningPage = wrapper
|
||||
.find(ActiveLearningPage)
|
||||
.childAt(0) as ReactWrapper<IActiveLearningPageProps, IActiveLearningPageState>;
|
||||
|
||||
expect(activeLearningPage.state().settings).toEqual(testProject.activeLearningSettings);
|
||||
expect(wrapper.find(ActiveLearningForm).props().settings).toEqual(testProject.activeLearningSettings);
|
||||
});
|
||||
|
||||
it("saves the active learning settings when the form is submitted", async () => {
|
||||
const testProject = MockFactory.createTestProject("TestProject");
|
||||
const activeLearningSettings: IActiveLearningSettings = {
|
||||
...testProject.activeLearningSettings,
|
||||
modelPathType: ModelPathType.Url,
|
||||
modelUrl: "http://myserver.com/custommodel",
|
||||
autoDetect: true,
|
||||
predictTag: true,
|
||||
};
|
||||
|
||||
const store = createReduxStore(MockFactory.initialState({
|
||||
currentProject: testProject,
|
||||
}));
|
||||
|
||||
const projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
|
||||
projectServiceMock.prototype.load = jest.fn((project) => Promise.resolve(project));
|
||||
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
|
||||
|
||||
const props = MockFactory.activeLearningProps();
|
||||
const saveProjectSpy = jest.spyOn(props.actions, "saveProject");
|
||||
saveProjectSpy.mockClear();
|
||||
|
||||
const wrapper = createComponent(store, props);
|
||||
|
||||
const activeLearningForm = wrapper.find(ActiveLearningForm);
|
||||
activeLearningForm.props().onSubmit(activeLearningSettings);
|
||||
|
||||
await MockFactory.flushUi();
|
||||
|
||||
expect(saveProjectSpy).toBeCalledWith(expect.objectContaining({
|
||||
...testProject,
|
||||
activeLearningSettings,
|
||||
}));
|
||||
|
||||
expect(toast.success).toBeCalledWith(strings.activeLearning.messages.saveSuccess);
|
||||
expect(props.history.goBack).toBeCalled();
|
||||
});
|
||||
|
||||
it("returns to the previous page when the form is cancelled", async () => {
|
||||
const testProject = MockFactory.createTestProject("TestProject");
|
||||
const store = createReduxStore(MockFactory.initialState({
|
||||
currentProject: testProject,
|
||||
}));
|
||||
|
||||
const props = MockFactory.activeLearningProps();
|
||||
const saveProjectSpy = jest.spyOn(props.actions, "saveProject");
|
||||
saveProjectSpy.mockClear();
|
||||
|
||||
const wrapper = createComponent(store, props);
|
||||
|
||||
wrapper.find(ActiveLearningForm).props().onCancel();
|
||||
|
||||
await MockFactory.flushUi();
|
||||
expect(props.history.goBack).toBeCalled();
|
||||
expect(saveProjectSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import { bindActionCreators } from "redux";
|
||||
import { IActiveLearningSettings, IProject, IApplicationState } from "../../../../models/applicationState";
|
||||
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
|
||||
import { strings } from "../../../../common/strings";
|
||||
import { ActiveLearningForm } from "./activeLearningForm";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export interface IActiveLearningPageProps extends RouteComponentProps, React.Props<ActiveLearningPage> {
|
||||
project: IProject;
|
||||
recentProjects: IProject[];
|
||||
actions: IProjectActions;
|
||||
}
|
||||
|
||||
export interface IActiveLearningPageState {
|
||||
settings: IActiveLearningSettings;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IApplicationState) {
|
||||
return {
|
||||
project: state.currentProject,
|
||||
recentProjects: state.recentProjects,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators(projectActions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class ActiveLearningPage extends React.Component<IActiveLearningPageProps, IActiveLearningPageState> {
|
||||
public state: IActiveLearningPageState = {
|
||||
settings: this.props.project ? this.props.project.activeLearningSettings : null,
|
||||
};
|
||||
|
||||
public async componentDidMount() {
|
||||
const projectId = this.props.match.params["projectId"];
|
||||
// If we are creating a new project check to see if there is a partial
|
||||
// project already created in local storage
|
||||
if (!this.props.project && projectId) {
|
||||
const projectToLoad = this.props.recentProjects.find((project) => project.id === projectId);
|
||||
if (projectToLoad) {
|
||||
await this.props.actions.loadProject(projectToLoad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IActiveLearningPageProps>) {
|
||||
if (prevProps.project !== this.props.project) {
|
||||
this.setState({ settings: this.props.project.activeLearningSettings });
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div className="project-settings-page">
|
||||
<div className="project-settings-page-settings m-3">
|
||||
<h3>
|
||||
<i className="fas fa-graduation-cap" />
|
||||
<span className="px-2">
|
||||
{strings.activeLearning.title}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="m-3">
|
||||
<ActiveLearningForm
|
||||
settings={this.state.settings}
|
||||
onSubmit={this.onFormSubmit}
|
||||
onCancel={this.onFormCancel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private onFormSubmit = async (settings: IActiveLearningSettings): Promise<void> => {
|
||||
const updatedProject: IProject = {
|
||||
...this.props.project,
|
||||
activeLearningSettings: settings,
|
||||
};
|
||||
|
||||
await this.props.actions.saveProject(updatedProject);
|
||||
toast.success(strings.activeLearning.messages.saveSuccess);
|
||||
this.props.history.goBack();
|
||||
}
|
||||
|
||||
private onFormCancel = (): void => {
|
||||
this.props.history.goBack();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export default class ActiveLearningPage extends React.Component {
|
||||
public render() {
|
||||
return (
|
||||
<div>ActiveLearningPage</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ describe("Editor Canvas", () => {
|
||||
const canvasProps: ICanvasProps = {
|
||||
selectedAsset: getAssetMetadata(),
|
||||
onAssetMetadataChanged: jest.fn(),
|
||||
onCanvasRendered: jest.fn(),
|
||||
editorMode: EditorMode.Rectangle,
|
||||
selectionMode: SelectionMode.RECT,
|
||||
project: MockFactory.createTestProject(),
|
||||
@@ -179,7 +180,7 @@ describe("Editor Canvas", () => {
|
||||
});
|
||||
const canvas = wrapper.instance() as Canvas;
|
||||
expect(wrapper.state().currentAsset).toEqual(assetMetadata);
|
||||
expect(() => canvas.updateCanvasToolsRegions()).not.toThrowError();
|
||||
expect(() => canvas.updateCanvasToolsRegionTags()).not.toThrowError();
|
||||
});
|
||||
|
||||
it("canvas content source is updated when asset is deactivated", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CanvasTools } from "vott-ct";
|
||||
import { RegionData } from "vott-ct/lib/js/CanvasTools/Core/RegionData";
|
||||
import {
|
||||
EditorMode, IAssetMetadata,
|
||||
IProject, IRegion, RegionType, IBoundingBox, ISize,
|
||||
IProject, IRegion, RegionType,
|
||||
} from "../../../../models/applicationState";
|
||||
import CanvasHelpers from "./canvasHelpers";
|
||||
import { AssetPreview, ContentSource } from "../../common/assetPreview/assetPreview";
|
||||
@@ -25,6 +25,7 @@ export interface ICanvasProps extends React.Props<Canvas> {
|
||||
children?: ReactElement<AssetPreview>;
|
||||
onAssetMetadataChanged?: (assetMetadata: IAssetMetadata) => void;
|
||||
onSelectedRegionsChanged?: (regions: IRegion[]) => void;
|
||||
onCanvasRendered?: (canvas: HTMLCanvasElement) => void;
|
||||
}
|
||||
|
||||
export interface ICanvasState {
|
||||
@@ -73,7 +74,8 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
|
||||
}
|
||||
|
||||
public componentDidUpdate = async (prevProps: Readonly<ICanvasProps>, prevState: Readonly<ICanvasState>) => {
|
||||
if (this.props.selectedAsset.asset.id !== prevProps.selectedAsset.asset.id) {
|
||||
// Handles asset changing
|
||||
if (this.props.selectedAsset !== prevProps.selectedAsset) {
|
||||
this.setState({ currentAsset: this.props.selectedAsset });
|
||||
}
|
||||
|
||||
@@ -83,9 +85,16 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
|
||||
this.editor.AS.setSelectionMode({ mode: this.props.selectionMode, template: options });
|
||||
}
|
||||
|
||||
const assetIdChanged = this.state.currentAsset.asset.id !== prevState.currentAsset.asset.id;
|
||||
|
||||
// When the selected asset has changed but is still the same asset id
|
||||
if (!assetIdChanged && this.state.currentAsset !== prevState.currentAsset) {
|
||||
this.refreshCanvasToolsRegions();
|
||||
}
|
||||
|
||||
// When the project tags change re-apply tags to regions
|
||||
if (this.props.project.tags !== prevProps.project.tags) {
|
||||
this.updateCanvasToolsRegions();
|
||||
this.updateCanvasToolsRegionTags();
|
||||
}
|
||||
|
||||
// Handles when the canvas is enabled & disabled
|
||||
@@ -192,7 +201,7 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
|
||||
return this.state.currentAsset.regions.filter((r) => selectedRegions.find((id) => r.id === id));
|
||||
}
|
||||
|
||||
public updateCanvasToolsRegions = (): void => {
|
||||
public updateCanvasToolsRegionTags = (): void => {
|
||||
for (const region of this.state.currentAsset.regions) {
|
||||
this.editor.RM.updateTagsById(
|
||||
region.id,
|
||||
@@ -447,6 +456,11 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
|
||||
private setContentSource = async (contentSource: ContentSource) => {
|
||||
try {
|
||||
await this.editor.addContentSource(contentSource as any);
|
||||
|
||||
if (this.props.onCanvasRendered) {
|
||||
const canvas = this.canvasZone.current.querySelector("canvas");
|
||||
this.props.onCanvasRendered(canvas);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
@@ -493,7 +507,7 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
|
||||
this.editor.RM.updateTagsById(update.id, CanvasHelpers.getTagsDescriptor(this.props.project.tags, update));
|
||||
}
|
||||
this.updateAssetRegions(updatedRegions);
|
||||
this.updateCanvasToolsRegions();
|
||||
this.updateCanvasToolsRegionTags();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { mount, ReactWrapper } from "enzyme";
|
||||
import React from "react";
|
||||
import EditorFooter, { IEditorFooterProps, IEditorFooterState } from "./editorFooter";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
|
||||
describe("Footer Component", () => {
|
||||
|
||||
const originalTags = MockFactory.createTestTags();
|
||||
|
||||
function createComponent(props: IEditorFooterProps): ReactWrapper<IEditorFooterProps, IEditorFooterState> {
|
||||
return mount(
|
||||
<EditorFooter {...props} />,
|
||||
);
|
||||
}
|
||||
|
||||
it("tags are initialized correctly", () => {
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
lockedTags: [],
|
||||
});
|
||||
const stateTags = wrapper.state().tags;
|
||||
expect(stateTags).toEqual(originalTags);
|
||||
});
|
||||
|
||||
it("tags are empty", () => {
|
||||
const wrapper = createComponent({
|
||||
tags: [],
|
||||
lockedTags: [],
|
||||
});
|
||||
const stateTags = wrapper.state()["tags"];
|
||||
expect(stateTags).toEqual([]);
|
||||
});
|
||||
|
||||
it("create a new tag from text box", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
lockedTags: [],
|
||||
onTagsChanged: onChangeHandler,
|
||||
});
|
||||
const newTagName = "My new tag";
|
||||
wrapper.find("input").simulate("change", { target: { value: newTagName } });
|
||||
wrapper.find("input").simulate("keyDown", { keyCode: 13 });
|
||||
expect(onChangeHandler).toBeCalled();
|
||||
|
||||
const tags = wrapper.state().tags;
|
||||
expect(tags).toHaveLength(originalTags.length + 1);
|
||||
expect(tags[tags.length - 1].name).toEqual(newTagName);
|
||||
});
|
||||
|
||||
it("create a new tag when no tags exist", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: null,
|
||||
lockedTags: [],
|
||||
onTagsChanged: onChangeHandler,
|
||||
});
|
||||
const newTagName = "My new tag";
|
||||
wrapper.find("input").simulate("change", { target: { value: newTagName } });
|
||||
wrapper.find("input").simulate("keyDown", { keyCode: 13 });
|
||||
expect(onChangeHandler).toBeCalled();
|
||||
|
||||
const tags = wrapper.state().tags;
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0].name).toEqual(newTagName);
|
||||
});
|
||||
|
||||
it("remove a tag", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
lockedTags: [],
|
||||
onTagsChanged: onChangeHandler,
|
||||
});
|
||||
expect(wrapper.state().tags).toHaveLength(originalTags.length);
|
||||
wrapper.find("a.ReactTags__remove")
|
||||
.last().simulate("click");
|
||||
expect(onChangeHandler).toBeCalled();
|
||||
const tags = wrapper.state().tags;
|
||||
expect(tags).toHaveLength(originalTags.length - 1);
|
||||
expect(tags[0].name).toEqual(originalTags[0].name);
|
||||
expect(tags[0].color).toEqual(originalTags[0].color);
|
||||
});
|
||||
|
||||
it("clicking 'ok' in modal closes and calls onChangeHandler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
lockedTags: [],
|
||||
onTagsChanged: onChangeHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name }, shiftKey: true});
|
||||
wrapper.find("button.btn.btn-success").simulate("click");
|
||||
expect(onChangeHandler).toBeCalled();
|
||||
});
|
||||
|
||||
it("clicking 'cancel' in modal closes and does not call onChangeHandler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
lockedTags: [],
|
||||
onTagsChanged: onChangeHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name }, shiftKey: true});
|
||||
wrapper.find("button.btn.btn-secondary").simulate("click");
|
||||
|
||||
expect(onChangeHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("clicking tag calls onTagClickHandler ", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
lockedTags: [],
|
||||
onTagsChanged: onChangeHandler,
|
||||
onTagClicked: onTagClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name }});
|
||||
|
||||
expect(onTagClickHandler).toBeCalledWith(originalTags[0]);
|
||||
});
|
||||
|
||||
it("clicking tag with ctrl does not call onTagClickHandler ", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
lockedTags: [],
|
||||
onTagsChanged: onChangeHandler,
|
||||
onTagClicked: onTagClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name }, shiftKey: true});
|
||||
|
||||
expect(onTagClickHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("componentDidUpdate check", async () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
lockedTags: [],
|
||||
onTagsChanged: onChangeHandler,
|
||||
onTagClicked: onTagClickHandler,
|
||||
});
|
||||
|
||||
wrapper.setProps({tags: [...originalTags, {color: "#808000", name: "NEWTAG"}]});
|
||||
|
||||
await MockFactory.flushUi();
|
||||
wrapper.update();
|
||||
|
||||
const tagChild = wrapper.find("div.tag");
|
||||
|
||||
expect(tagChild.length).toEqual(originalTags.length + 1);
|
||||
expect(tagChild.last().text()).toMatch("NEWTAG");
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import React from "react";
|
||||
import { TagEditorModal, TagsInput } from "vott-react";
|
||||
import { strings } from "../../../../common/strings";
|
||||
import { ITag } from "../../../../models/applicationState";
|
||||
|
||||
/**
|
||||
* Properties for Editor Footer
|
||||
* @member tags - Array of tags for TagsInput component
|
||||
* @member lockedTags - Tags currently locked for applying to regions
|
||||
* @member displayHotKeys - Determines whether indices for first 10 tags are shown on tag buttons
|
||||
* @member onTagsChanged - Function to call when tags are changed
|
||||
* @member onTagClicked - Function to call when tags are clicked
|
||||
*/
|
||||
export interface IEditorFooterProps {
|
||||
tags: ITag[];
|
||||
lockedTags: string[];
|
||||
onTagsChanged?: (value) => void;
|
||||
onTagClicked?: (value) => void;
|
||||
onCtrlTagClicked?: (value) => void;
|
||||
onCtrlShiftTagClicked?: (value) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for Editor Footer
|
||||
* @member tags - Array of tags for TagsInput component
|
||||
*/
|
||||
export interface IEditorFooterState {
|
||||
tags: ITag[];
|
||||
selectedTag: ITag;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name - Editor Footer
|
||||
* @description - Footer of the editor page. Contains EditorTagsInput component
|
||||
*/
|
||||
export default class EditorFooter extends React.Component<IEditorFooterProps, IEditorFooterState> {
|
||||
|
||||
public state = {
|
||||
tags: this.props.tags,
|
||||
selectedTag: null,
|
||||
};
|
||||
|
||||
private tagsInput: React.RefObject<TagsInput> = React.createRef<TagsInput>();
|
||||
private tagEditorModal: React.RefObject<TagEditorModal> = React.createRef<TagEditorModal>();
|
||||
|
||||
public componentDidUpdate(prevProp: IEditorFooterProps) {
|
||||
if (prevProp.tags !== this.props.tags) {
|
||||
this.setState({
|
||||
tags: this.props.tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div>
|
||||
<TagsInput
|
||||
tags={this.state.tags}
|
||||
ref={this.tagsInput}
|
||||
onChange={this.onTagsChanged}
|
||||
onTagClick={this.onTagClicked}
|
||||
onCtrlTagClick={this.props.onCtrlTagClicked}
|
||||
onShiftTagClick={this.onShiftTagClicked}
|
||||
onCtrlShiftTagClick={this.props.onCtrlShiftTagClicked}
|
||||
getTagSpan={this.getTagSpan}
|
||||
/>
|
||||
<TagEditorModal
|
||||
ref={this.tagEditorModal}
|
||||
onOk={this.onTagModalOk}
|
||||
tagNameText={strings.tags.modal.name}
|
||||
tagColorText={strings.tags.modal.color}
|
||||
saveText={strings.common.save}
|
||||
cancelText={strings.common.cancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private onTagClicked = (tag: ITag) => {
|
||||
this.props.onTagClicked(tag);
|
||||
this.blurTagsInput();
|
||||
}
|
||||
|
||||
private onShiftTagClicked = (tag: ITag) => {
|
||||
this.setState({
|
||||
selectedTag: tag,
|
||||
}, () => {
|
||||
this.tagEditorModal.current.open(tag);
|
||||
});
|
||||
}
|
||||
|
||||
private onTagModalOk = (oldTag: ITag, newTag: ITag) => {
|
||||
this.tagsInput.current.updateTag(oldTag, newTag);
|
||||
this.tagEditorModal.current.close();
|
||||
}
|
||||
|
||||
private onTagsChanged = (tags) => {
|
||||
this.setState({
|
||||
tags,
|
||||
}, () => {
|
||||
this.props.onTagsChanged(this.state);
|
||||
this.blurTagsInput();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the display index of the tag in the span of the first 10 tags
|
||||
* Also adds necessary stylings to all locked tags
|
||||
* @param name Name of tag
|
||||
* @param index Index of tag
|
||||
*/
|
||||
private getTagSpan = (name: string, index: number) => {
|
||||
let className = "tag-span";
|
||||
let displayName = name;
|
||||
if (index < 10) {
|
||||
const displayIndex = (index === 9) ? 0 : index + 1;
|
||||
displayName = `[${displayIndex}] ` + name;
|
||||
className += " tag-span-index";
|
||||
}
|
||||
if (this.props.lockedTags.find((t) => t === name)) {
|
||||
className += " locked-tag";
|
||||
}
|
||||
return (
|
||||
<span className={className}>{displayName}</span>
|
||||
);
|
||||
}
|
||||
|
||||
private blurTagsInput() {
|
||||
const inputElement: HTMLElement = document.querySelector(".ReactTags__tagInputField");
|
||||
if (inputElement) {
|
||||
setImmediate(() => inputElement.blur());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import EditorPage, { IEditorPageProps, IEditorPageState } from "./editorPage";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
import {
|
||||
IApplicationState, IAssetMetadata, IProject,
|
||||
EditorMode, IAsset, AssetState, AssetType, ISize,
|
||||
EditorMode, IAsset, AssetState, ISize, IActiveLearningSettings, ModelPathType,
|
||||
} from "../../../../models/applicationState";
|
||||
import { AssetProviderFactory } from "../../../../providers/storage/assetProviderFactory";
|
||||
import createReduxStore from "../../../../redux/store/store";
|
||||
@@ -29,6 +29,11 @@ import { appInfo } from "../../../../common/appInfo";
|
||||
import SplitPane from "react-split-pane";
|
||||
import EditorSideBar from "./editorSideBar";
|
||||
import Alert from "../../common/alert/alert";
|
||||
import registerMixins from "../../../../registerMixins";
|
||||
import { TagInput } from "../../common/tagInput/tagInput";
|
||||
import { EditorToolbar } from "./editorToolbar";
|
||||
import { ToolbarItem } from "../../toolbar/toolbarItem";
|
||||
import { ActiveLearningService } from "../../../../services/activeLearningService";
|
||||
|
||||
function createComponent(store, props: IEditorPageProps): ReactWrapper<IEditorPageProps, IEditorPageState, EditorPage> {
|
||||
return mount(
|
||||
@@ -58,9 +63,20 @@ describe("Editor Page Component", () => {
|
||||
let assetServiceMock: jest.Mocked<typeof AssetService> = null;
|
||||
let projectServiceMock: jest.Mocked<typeof ProjectService> = null;
|
||||
|
||||
const electronMock = {
|
||||
remote: {
|
||||
app: {
|
||||
getAppPath: jest.fn(() => ""),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const testAssets: IAsset[] = MockFactory.createTestAssets(5);
|
||||
|
||||
beforeAll(() => {
|
||||
registerToolbar();
|
||||
window["require"] = jest.fn(() => electronMock);
|
||||
|
||||
const editorMock = Editor as any;
|
||||
editorMock.prototype.addContentSource = jest.fn(() => Promise.resolve());
|
||||
editorMock.prototype.scaleRegionToSourceSize = jest.fn((regionData: any) => regionData);
|
||||
@@ -125,12 +141,10 @@ describe("Editor Page Component", () => {
|
||||
|
||||
const wrapper = createComponent(store, props);
|
||||
const editorPage = wrapper.find(EditorPage).childAt(0);
|
||||
expect(getState(wrapper).project).toBeNull();
|
||||
|
||||
editorPage.props().project = testProject;
|
||||
await MockFactory.flushUi();
|
||||
expect(editorPage.props().project).toEqual(testProject);
|
||||
expect(getState(wrapper).project).toEqual(testProject);
|
||||
});
|
||||
|
||||
it("Loads and merges project assets with asset provider assets when state changes", async () => {
|
||||
@@ -163,7 +177,7 @@ describe("Editor Page Component", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Raises onAssetSelected handler when an asset is selected from the sidebar", async () => {
|
||||
it("Default asset is loaded and saved during initial page rendering", async () => {
|
||||
// create test project and asset
|
||||
const testProject = MockFactory.createTestProject("TestProject");
|
||||
const defaultAsset = testAssets[0];
|
||||
@@ -181,6 +195,7 @@ describe("Editor Page Component", () => {
|
||||
const editorPage = wrapper.find(EditorPage).childAt(0) as ReactWrapper<IEditorPageProps, IEditorPageState>;
|
||||
|
||||
await MockFactory.flushUi();
|
||||
wrapper.update();
|
||||
|
||||
const expectedAsset = editorPage.state().assets[0];
|
||||
const partialProject = {
|
||||
@@ -333,45 +348,6 @@ describe("Editor Page Component", () => {
|
||||
expect(saveProjectSpy).toBeCalledWith(expect.objectContaining(partialProject));
|
||||
});
|
||||
|
||||
describe("Editor Page Component Forcing Tag Scenario", () => {
|
||||
it("Detect new Tag from asset metadata when selecting the Asset", async () => {
|
||||
const getAssetMetadataMock = assetServiceMock.prototype.getAssetMetadata as jest.Mock;
|
||||
getAssetMetadataMock.mockImplementationOnce((asset) => {
|
||||
const assetMetadata: IAssetMetadata = {
|
||||
asset: { ...asset },
|
||||
regions: [{ ...MockFactory.createTestRegion(), tags: ["NEWTAG"] }],
|
||||
version: appInfo.version,
|
||||
};
|
||||
return Promise.resolve(assetMetadata);
|
||||
});
|
||||
|
||||
// create test project and asset
|
||||
const testProject = MockFactory.createTestProject("TestProject");
|
||||
|
||||
// mock store and props
|
||||
const store = createStore(testProject, true);
|
||||
const props = MockFactory.editorPageProps(testProject.id);
|
||||
|
||||
const saveProjectSpy = jest.spyOn(props.actions, "saveProject");
|
||||
|
||||
// create mock editor page
|
||||
createComponent(store, props);
|
||||
|
||||
const partialProjectToBeSaved = {
|
||||
id: testProject.id,
|
||||
name: testProject.name,
|
||||
tags: expect.arrayContaining([{
|
||||
name: "NEWTAG",
|
||||
color: expect.any(String),
|
||||
}]),
|
||||
};
|
||||
|
||||
await MockFactory.flushUi();
|
||||
|
||||
expect(saveProjectSpy).toBeCalledWith(expect.objectContaining(partialProjectToBeSaved));
|
||||
});
|
||||
});
|
||||
|
||||
it("When an image is updated the asset metadata is updated", async () => {
|
||||
const testProject = MockFactory.createTestProject("TestProject");
|
||||
const store = createStore(testProject, true);
|
||||
@@ -497,7 +473,6 @@ describe("Editor Page Component", () => {
|
||||
const removeAllRegionsConfirm = jest.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
registerToolbar();
|
||||
const clipboard = (navigator as any).clipboard;
|
||||
if (!(clipboard && clipboard.writeText)) {
|
||||
(navigator as any).clipboard = {
|
||||
@@ -657,6 +632,11 @@ describe("Editor Page Component", () => {
|
||||
});
|
||||
|
||||
describe("Basic tag interaction tests", () => {
|
||||
|
||||
beforeAll(() => {
|
||||
registerMixins();
|
||||
});
|
||||
|
||||
it("tags are initialized correctly", () => {
|
||||
const project = MockFactory.createTestProject();
|
||||
const store = createReduxStore({
|
||||
@@ -665,26 +645,31 @@ describe("Editor Page Component", () => {
|
||||
});
|
||||
|
||||
const wrapper = createComponent(store, MockFactory.editorPageProps());
|
||||
expect(getState(wrapper).project.tags).toEqual(project.tags);
|
||||
expect(wrapper.find(TagInput).props().tags).toEqual(project.tags);
|
||||
});
|
||||
|
||||
it("create a new tag from text box", () => {
|
||||
it("create a new tag updates project tags", async () => {
|
||||
const project = MockFactory.createTestProject();
|
||||
const store = createReduxStore({
|
||||
...MockFactory.initialState(),
|
||||
currentProject: project,
|
||||
});
|
||||
|
||||
const wrapper = createComponent(store, MockFactory.editorPageProps());
|
||||
expect(getState(wrapper).project.tags).toEqual(project.tags);
|
||||
await waitForSelectedAsset(wrapper);
|
||||
|
||||
const newTagName = "My new tag";
|
||||
wrapper.find("div.tag-input-toolbar-item.plus").simulate("click");
|
||||
wrapper.find(".tag-input-box").simulate("keydown", { key: "Enter", target: { value: newTagName } });
|
||||
const newTag = MockFactory.createTestTag("NewTag");
|
||||
const updatedTags = [...project.tags, newTag];
|
||||
wrapper.find(TagInput).props().onChange(updatedTags);
|
||||
|
||||
const stateTags = getState(wrapper).project.tags;
|
||||
await MockFactory.flushUi();
|
||||
wrapper.update();
|
||||
|
||||
expect(stateTags).toHaveLength(project.tags.length + 1);
|
||||
expect(stateTags[stateTags.length - 1].name).toEqual(newTagName);
|
||||
const editorPage = wrapper.find(EditorPage).childAt(0) as ReactWrapper<IEditorPageProps>;
|
||||
const projectTags = editorPage.props().project.tags;
|
||||
|
||||
expect(projectTags).toHaveLength(updatedTags.length);
|
||||
expect(projectTags[projectTags.length - 1].name).toEqual(newTag.name);
|
||||
});
|
||||
|
||||
it("Remove a tag", async () => {
|
||||
@@ -697,12 +682,20 @@ describe("Editor Page Component", () => {
|
||||
const wrapper = createComponent(store, MockFactory.editorPageProps());
|
||||
await waitForSelectedAsset(wrapper);
|
||||
|
||||
expect(getState(wrapper).project.tags).toEqual(project.tags);
|
||||
wrapper.find(".tag-content").last().simulate("click");
|
||||
wrapper.find("i.tag-input-toolbar-icon.fas.fa-trash").simulate("click");
|
||||
const tagToDelete = project.tags[project.tags.length - 1];
|
||||
wrapper.find(TagInput).props().onTagDeleted(tagToDelete.name);
|
||||
|
||||
const stateTags = getState(wrapper).project.tags;
|
||||
expect(stateTags).toHaveLength(project.tags.length - 1);
|
||||
// Accept the modal delete warning
|
||||
wrapper.update();
|
||||
wrapper.find(".modal-footer button").first().simulate("click");
|
||||
|
||||
await MockFactory.flushUi();
|
||||
wrapper.update();
|
||||
|
||||
const editorPage = wrapper.find(EditorPage).childAt(0) as ReactWrapper<IEditorPageProps>;
|
||||
const projectTags = editorPage.props().project.tags;
|
||||
|
||||
expect(projectTags).toHaveLength(project.tags.length - 1);
|
||||
});
|
||||
|
||||
it("Adds tag to locked tags when CmdOrCtrl clicked", async () => {
|
||||
@@ -807,6 +800,72 @@ describe("Editor Page Component", () => {
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Active Learning", async () => {
|
||||
let wrapper: ReactWrapper;
|
||||
let editorPage: ReactWrapper<IEditorPageProps, IEditorPageState>;
|
||||
const activeLearningMock = ActiveLearningService as jest.Mocked<typeof ActiveLearningService>;
|
||||
|
||||
async function beforeActiveLearningTest(activeLearningSettings?: IActiveLearningSettings) {
|
||||
document.querySelector = MockFactory.mockCanvas();
|
||||
activeLearningMock.prototype.isModelLoaded = jest.fn(() => true);
|
||||
activeLearningMock.prototype.predictRegions = jest.fn((canvas, assetMetadtata) => {
|
||||
return Promise.resolve({
|
||||
...assetMetadtata,
|
||||
predicted: true,
|
||||
});
|
||||
});
|
||||
const project = MockFactory.createTestProject();
|
||||
|
||||
if (activeLearningSettings) {
|
||||
project.activeLearningSettings = activeLearningSettings;
|
||||
}
|
||||
|
||||
const store = createReduxStore({
|
||||
...MockFactory.initialState(),
|
||||
currentProject: project,
|
||||
});
|
||||
|
||||
wrapper = createComponent(store, MockFactory.editorPageProps());
|
||||
await waitForSelectedAsset(wrapper);
|
||||
wrapper.update();
|
||||
editorPage = wrapper.find(EditorPage).childAt(0);
|
||||
}
|
||||
|
||||
it("predicts regions when auto detect has been enabled", async () => {
|
||||
const activeLearningSettings: IActiveLearningSettings = {
|
||||
modelPathType: ModelPathType.Coco,
|
||||
autoDetect: true,
|
||||
predictTag: true,
|
||||
};
|
||||
|
||||
await beforeActiveLearningTest(activeLearningSettings);
|
||||
|
||||
editorPage.find(Canvas).props().onCanvasRendered(document.createElement("canvas"));
|
||||
expect(activeLearningMock.prototype.predictRegions).toBeCalled();
|
||||
});
|
||||
|
||||
it("predicts regions when toolbar item is selected", async () => {
|
||||
await beforeActiveLearningTest();
|
||||
|
||||
const toolbarItem = {
|
||||
props: {
|
||||
name: ToolbarItemName.ActiveLearning,
|
||||
},
|
||||
};
|
||||
|
||||
const selectedAsset = editorPage.state().selectedAsset;
|
||||
wrapper.find(EditorToolbar).props().onToolbarItemSelected(toolbarItem as ToolbarItem);
|
||||
|
||||
await MockFactory.flushUi();
|
||||
|
||||
expect(activeLearningMock.prototype.predictRegions).toBeCalledWith(expect.anything(), selectedAsset);
|
||||
expect(assetServiceMock.prototype.save).toBeCalledWith({
|
||||
...selectedAsset,
|
||||
predicted: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createStore(project: IProject, setCurrentProject: boolean = false): Store<any, AnyAction> {
|
||||
|
||||
@@ -10,14 +10,14 @@ import { strings } from "../../../../common/strings";
|
||||
import {
|
||||
AssetState, AssetType, EditorMode, IApplicationState,
|
||||
IAppSettings, IAsset, IAssetMetadata, IProject, IRegion,
|
||||
ISize, ITag,
|
||||
ISize, ITag, IAdditionalPageSettings, AppError, ErrorCode,
|
||||
} from "../../../../models/applicationState";
|
||||
import { IToolbarItemRegistration, ToolbarItemFactory } from "../../../../providers/toolbar/toolbarItemFactory";
|
||||
import IApplicationActions, * as applicationActions from "../../../../redux/actions/applicationActions";
|
||||
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
|
||||
import { ToolbarItemName } from "../../../../registerToolbar";
|
||||
import { AssetService } from "../../../../services/assetService";
|
||||
import { AssetPreview, IAssetPreviewSettings } from "../../common/assetPreview/assetPreview";
|
||||
import { AssetPreview } from "../../common/assetPreview/assetPreview";
|
||||
import { KeyboardBinding } from "../../common/keyboardBinding/keyboardBinding";
|
||||
import { KeyEventType } from "../../common/keyboardManager/keyboardManager";
|
||||
import { TagInput } from "../../common/tagInput/tagInput";
|
||||
@@ -28,8 +28,9 @@ import "./editorPage.scss";
|
||||
import EditorSideBar from "./editorSideBar";
|
||||
import { EditorToolbar } from "./editorToolbar";
|
||||
import Alert from "../../common/alert/alert";
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const tagColors = require("../../common/tagColors.json");
|
||||
import Confirm from "../../common/confirm/confirm";
|
||||
import { ActiveLearningService } from "../../../../services/activeLearningService";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
/**
|
||||
* Properties for Editor Page
|
||||
@@ -50,8 +51,6 @@ export interface IEditorPageProps extends RouteComponentProps, React.Props<Edito
|
||||
* State for Editor Page
|
||||
*/
|
||||
export interface IEditorPageState {
|
||||
/** Project being editor */
|
||||
project: IProject;
|
||||
/** Array of assets in project */
|
||||
assets: IAsset[];
|
||||
/** The editor mode to set for canvas tools */
|
||||
@@ -65,7 +64,7 @@ export interface IEditorPageState {
|
||||
/** The child assets used for nest asset typs */
|
||||
childAssets?: IAsset[];
|
||||
/** Additional settings for asset previews */
|
||||
additionalSettings?: IAssetPreviewSettings;
|
||||
additionalSettings?: IAdditionalPageSettings;
|
||||
/** Most recently selected tag */
|
||||
selectedTag: string;
|
||||
/** Tags locked for region labeling */
|
||||
@@ -102,24 +101,28 @@ function mapDispatchToProps(dispatch) {
|
||||
*/
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class EditorPage extends React.Component<IEditorPageProps, IEditorPageState> {
|
||||
|
||||
public state: IEditorPageState = {
|
||||
project: this.props.project,
|
||||
selectedTag: null,
|
||||
lockedTags: [],
|
||||
selectionMode: SelectionMode.RECT,
|
||||
assets: [],
|
||||
childAssets: [],
|
||||
editorMode: EditorMode.Rectangle,
|
||||
additionalSettings: { videoSettings: (this.props.project) ? this.props.project.videoSettings : null },
|
||||
additionalSettings: {
|
||||
videoSettings: (this.props.project) ? this.props.project.videoSettings : null,
|
||||
activeLearningSettings: (this.props.project) ? this.props.project.activeLearningSettings : null,
|
||||
},
|
||||
thumbnailSize: this.props.appSettings.thumbnailSize || { width: 175, height: 155 },
|
||||
isValid: true,
|
||||
showInvalidRegionWarning: false,
|
||||
};
|
||||
|
||||
private activeLearningService: ActiveLearningService = null;
|
||||
private loadingProjectAssets: boolean = false;
|
||||
private toolbarItems: IToolbarItemRegistration[] = ToolbarItemFactory.getToolbarItems();
|
||||
private canvas: RefObject<Canvas> = React.createRef();
|
||||
private renameTagConfirm: React.RefObject<Confirm> = React.createRef();
|
||||
private deleteTagConfirm: React.RefObject<Confirm> = React.createRef();
|
||||
|
||||
public async componentDidMount() {
|
||||
const projectId = this.props.match.params["projectId"];
|
||||
@@ -129,9 +132,11 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
||||
const project = this.props.recentProjects.find((project) => project.id === projectId);
|
||||
await this.props.actions.loadProject(project);
|
||||
}
|
||||
|
||||
this.activeLearningService = new ActiveLearningService(this.props.project.activeLearningSettings);
|
||||
}
|
||||
|
||||
public async componentDidUpdate() {
|
||||
public async componentDidUpdate(prevProps: Readonly<IEditorPageProps>) {
|
||||
if (this.props.project && this.state.assets.length === 0) {
|
||||
await this.loadProjectAssets();
|
||||
}
|
||||
@@ -139,12 +144,18 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
||||
// Navigating directly to the page via URL (ie, http://vott/projects/a1b2c3dEf/edit) sets the default state
|
||||
// before props has been set, this updates the project and additional settings to be valid once props are
|
||||
// retrieved.
|
||||
if (!this.state.project && this.props.project) {
|
||||
if (this.props.project && !prevProps.project) {
|
||||
this.setState({
|
||||
project: this.props.project,
|
||||
additionalSettings: { videoSettings: (this.props.project) ? this.props.project.videoSettings : null },
|
||||
additionalSettings: {
|
||||
videoSettings: (this.props.project) ? this.props.project.videoSettings : null,
|
||||
activeLearningSettings: (this.props.project) ? this.props.project.activeLearningSettings : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.project && prevProps.project && this.props.project.tags !== prevProps.project.tags) {
|
||||
this.updateRootAssets();
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
@@ -206,6 +217,7 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
||||
ref={this.canvas}
|
||||
selectedAsset={this.state.selectedAsset}
|
||||
onAssetMetadataChanged={this.onAssetMetadataChanged}
|
||||
onCanvasRendered={this.onCanvasRendered}
|
||||
onSelectedRegionsChanged={this.onSelectedRegionsChanged}
|
||||
editorMode={this.state.editorMode}
|
||||
selectionMode={this.state.selectionMode}
|
||||
@@ -232,8 +244,20 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
||||
onLockedTagsChange={this.onLockedTagsChanged}
|
||||
onTagClick={this.onTagClicked}
|
||||
onCtrlTagClick={this.onCtrlTagClicked}
|
||||
onTagRenamed={this.confirmTagRenamed}
|
||||
onTagDeleted={this.confirmTagDeleted}
|
||||
/>
|
||||
</div>
|
||||
<Confirm title={strings.editorPage.tags.rename.title}
|
||||
ref={this.renameTagConfirm}
|
||||
message={strings.editorPage.tags.rename.confirmation}
|
||||
confirmButtonColor="danger"
|
||||
onConfirm={this.onTagRenamed} />
|
||||
<Confirm title={strings.editorPage.tags.delete.title}
|
||||
ref={this.deleteTagConfirm}
|
||||
message={strings.editorPage.tags.delete.confirmation}
|
||||
confirmButtonColor="danger"
|
||||
onConfirm={this.onTagDeleted} />
|
||||
</div>
|
||||
</SplitPane>
|
||||
<Alert show={this.state.showInvalidRegionWarning}
|
||||
@@ -288,6 +312,49 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
||||
}, () => this.canvas.current.applyTag(tag.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open confirm dialog for tag renaming
|
||||
*/
|
||||
private confirmTagRenamed = (tagName: string, newTagName: string): void => {
|
||||
this.renameTagConfirm.current.open(tagName, newTagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames tag in assets and project, and saves files
|
||||
* @param tagName Name of tag to be renamed
|
||||
* @param newTagName New name of tag
|
||||
*/
|
||||
private onTagRenamed = async (tagName: string, newTagName: string): Promise<void> => {
|
||||
const assetUpdates = await this.props.actions.updateProjectTag(this.props.project, tagName, newTagName);
|
||||
const selectedAsset = assetUpdates.find((am) => am.asset.id === this.state.selectedAsset.asset.id);
|
||||
|
||||
if (selectedAsset) {
|
||||
if (selectedAsset) {
|
||||
this.setState({ selectedAsset });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Confirm dialog for tag deletion
|
||||
*/
|
||||
private confirmTagDeleted = (tagName: string): void => {
|
||||
this.deleteTagConfirm.current.open(tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes tag from assets and projects and saves files
|
||||
* @param tagName Name of tag to be deleted
|
||||
*/
|
||||
private onTagDeleted = async (tagName: string): Promise<void> => {
|
||||
const assetUpdates = await this.props.actions.deleteProjectTag(this.props.project, tagName);
|
||||
const selectedAsset = assetUpdates.find((am) => am.asset.id === this.state.selectedAsset.asset.id);
|
||||
|
||||
if (selectedAsset) {
|
||||
this.setState({ selectedAsset });
|
||||
}
|
||||
}
|
||||
|
||||
private onCtrlTagClicked = (tag: ITag): void => {
|
||||
const locked = this.state.lockedTags;
|
||||
this.setState({
|
||||
@@ -419,21 +486,28 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
||||
this.setState({ childAssets, assets, isValid: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised when the asset binary has been painted onto the canvas tools rendering canvas
|
||||
*/
|
||||
private onCanvasRendered = async (canvas: HTMLCanvasElement) => {
|
||||
// When active learning auto-detect is enabled
|
||||
// run predictions when asset changes
|
||||
if (this.props.project.activeLearningSettings.autoDetect && !this.state.selectedAsset.asset.predicted) {
|
||||
await this.predictRegions(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
private onSelectedRegionsChanged = (selectedRegions: IRegion[]) => {
|
||||
this.setState({ selectedRegions });
|
||||
}
|
||||
|
||||
private onTagsChanged = (tags) => {
|
||||
private onTagsChanged = async (tags) => {
|
||||
const project = {
|
||||
...this.props.project,
|
||||
tags,
|
||||
};
|
||||
this.setState({ project }, async () => {
|
||||
await this.props.actions.saveProject(project);
|
||||
if (this.canvas.current) {
|
||||
this.canvas.current.updateCanvasToolsRegions();
|
||||
}
|
||||
});
|
||||
|
||||
await this.props.actions.saveProject(project);
|
||||
}
|
||||
|
||||
private onLockedTagsChanged = (lockedTags: string[]) => {
|
||||
@@ -484,6 +558,41 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
||||
case ToolbarItemName.RemoveAllRegions:
|
||||
this.canvas.current.confirmRemoveAllRegions();
|
||||
break;
|
||||
case ToolbarItemName.ActiveLearning:
|
||||
await this.predictRegions();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private predictRegions = async (canvas?: HTMLCanvasElement) => {
|
||||
canvas = canvas || document.querySelector("canvas");
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the configured ML model
|
||||
if (!this.activeLearningService.isModelLoaded()) {
|
||||
let toastId: number = null;
|
||||
try {
|
||||
toastId = toast.info(strings.activeLearning.messages.loadingModel, { autoClose: false });
|
||||
await this.activeLearningService.ensureModelLoaded();
|
||||
} catch (e) {
|
||||
toast.error(strings.activeLearning.messages.errorLoadModel);
|
||||
return;
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}
|
||||
|
||||
// Predict and add regions to current asset
|
||||
try {
|
||||
const updatedAssetMetadata = await this.activeLearningService
|
||||
.predictRegions(canvas, this.state.selectedAsset);
|
||||
|
||||
await this.onAssetMetadataChanged(updatedAssetMetadata);
|
||||
this.setState({ selectedAsset: updatedAssetMetadata });
|
||||
} catch (e) {
|
||||
throw new AppError(ErrorCode.ActiveLearningPredictionError, "Error predicting regions");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +632,6 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
||||
}
|
||||
|
||||
const assetMetadata = await this.props.actions.loadAssetMetadata(this.props.project, asset);
|
||||
await this.updateProjectTagsFromAsset(assetMetadata);
|
||||
|
||||
try {
|
||||
if (!assetMetadata.asset.size) {
|
||||
@@ -541,32 +649,6 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
||||
});
|
||||
}
|
||||
|
||||
private async updateProjectTagsFromAsset(asset: IAssetMetadata) {
|
||||
const assetTags = new Set();
|
||||
asset.regions.forEach((region) => region.tags.forEach((tag) => assetTags.add(tag)));
|
||||
|
||||
const newTags: ITag[] = this.props.project.tags ? [...this.props.project.tags] : [];
|
||||
let updateTags = false;
|
||||
|
||||
assetTags.forEach((tag) => {
|
||||
if (!this.props.project.tags || this.props.project.tags.length === 0 ||
|
||||
!this.props.project.tags.find((projectTag) => tag === projectTag.name)) {
|
||||
newTags.push({
|
||||
name: tag,
|
||||
color: tagColors[newTags.length % tagColors.length],
|
||||
});
|
||||
updateTags = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (updateTags) {
|
||||
asset.asset.state = AssetState.Tagged;
|
||||
const newProject = { ...this.props.project, tags: newTags };
|
||||
await this.props.actions.saveAssetMetadata(newProject, asset);
|
||||
await this.props.actions.saveProject(newProject);
|
||||
}
|
||||
}
|
||||
|
||||
private loadProjectAssets = async (): Promise<void> => {
|
||||
if (this.loadingProjectAssets || this.state.assets.length > 0) {
|
||||
return;
|
||||
@@ -598,4 +680,19 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
||||
this.loadingProjectAssets = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the root asset list from the project assets
|
||||
*/
|
||||
private updateRootAssets = () => {
|
||||
const updatedAssets = [...this.state.assets];
|
||||
updatedAssets.forEach((asset) => {
|
||||
const projectAsset = this.props.project.assets[asset.id];
|
||||
if (projectAsset) {
|
||||
asset.state = projectAsset.state;
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({ assets: updatedAssets });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("Export Form Component", () => {
|
||||
providerType: "vottJson",
|
||||
providerOptions: {
|
||||
assetState: ExportAssetState.Tagged,
|
||||
includeImages: true,
|
||||
},
|
||||
},
|
||||
onSubmit: onSubmitHandler,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import Form, { Widget, FormValidation, IChangeEvent, ISubmitEvent } from "react-jsonschema-form";
|
||||
import { getDefaultFormState } from "react-jsonschema-form/lib/utils";
|
||||
import { addLocValues, strings } from "../../../../common/strings";
|
||||
import { IExportFormat, IExportProviderOptions } from "../../../../models/applicationState";
|
||||
import { ExportProviderFactory } from "../../../../providers/export/exportProviderFactory";
|
||||
@@ -115,7 +116,7 @@ export default class ExportForm extends React.Component<IExportFormProps, IExpor
|
||||
if (providerType !== this.state.providerName) {
|
||||
this.bindForm(args.formData, true);
|
||||
} else {
|
||||
this.setState({ formData: { ...args.formData } });
|
||||
this.bindForm(args.formData, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,11 +161,11 @@ export default class ExportForm extends React.Component<IExportFormProps, IExpor
|
||||
}
|
||||
|
||||
const formData = { ...exportFormat };
|
||||
if (resetProviderOptions) {
|
||||
formData.providerOptions = {} as IExportProviderOptions;
|
||||
}
|
||||
const providerOptions = resetProviderOptions ? {} : exportFormat.providerOptions;
|
||||
const providerDefaults = getDefaultFormState(newFormSchema.properties.providerOptions, providerOptions);
|
||||
|
||||
formData.providerType = providerType;
|
||||
formData.providerOptions = providerDefaults as IExportProviderOptions;
|
||||
|
||||
this.setState({
|
||||
providerName: providerType,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export default class ProfileSettingsPage extends React.Component {
|
||||
public render() {
|
||||
return (
|
||||
<div>ProfileSettingsPage</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,9 @@ describe("Project Form Component", () => {
|
||||
const appSettings = MockFactory.appSettings();
|
||||
const connections = MockFactory.createTestConnections();
|
||||
let wrapper: ReactWrapper<IProjectFormProps, IProjectFormState> = null;
|
||||
let onSubmitHandler: jest.Mock = null;
|
||||
let onCancelHandler: jest.Mock = null;
|
||||
const onSubmitHandler = jest.fn();
|
||||
const onChangeHandler = jest.fn();
|
||||
const onCancelHandler = jest.fn();
|
||||
|
||||
function createComponent(props: IProjectFormProps) {
|
||||
return mount(
|
||||
@@ -33,13 +34,16 @@ describe("Project Form Component", () => {
|
||||
|
||||
describe("Completed project", () => {
|
||||
beforeEach(() => {
|
||||
onSubmitHandler = jest.fn();
|
||||
onCancelHandler = jest.fn();
|
||||
onChangeHandler.mockClear();
|
||||
onSubmitHandler.mockClear();
|
||||
onCancelHandler.mockClear();
|
||||
|
||||
wrapper = createComponent({
|
||||
project,
|
||||
connections,
|
||||
appSettings,
|
||||
onSubmit: onSubmitHandler,
|
||||
onChange: onChangeHandler,
|
||||
onCancel: onCancelHandler,
|
||||
});
|
||||
});
|
||||
@@ -76,10 +80,14 @@ describe("Project Form Component", () => {
|
||||
|
||||
const form = wrapper.find("form");
|
||||
form.simulate("submit");
|
||||
expect(onSubmitHandler).toBeCalledWith({
|
||||
|
||||
const expectedProject = {
|
||||
...project,
|
||||
name: newName,
|
||||
});
|
||||
};
|
||||
|
||||
expect(onChangeHandler).toBeCalled();
|
||||
expect(onSubmitHandler).toBeCalledWith(expectedProject);
|
||||
});
|
||||
|
||||
it("starting project should update description upon submission", () => {
|
||||
@@ -92,10 +100,14 @@ describe("Project Form Component", () => {
|
||||
|
||||
const form = wrapper.find("form");
|
||||
form.simulate("submit");
|
||||
expect(onSubmitHandler).toBeCalledWith({
|
||||
|
||||
const expectedProject = {
|
||||
...project,
|
||||
description: newDescription,
|
||||
});
|
||||
};
|
||||
|
||||
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
|
||||
expect(onSubmitHandler).toBeCalledWith(expectedProject);
|
||||
});
|
||||
|
||||
it("starting project should update source connection ID upon submission", () => {
|
||||
@@ -109,11 +121,14 @@ describe("Project Form Component", () => {
|
||||
expect(wrapper.state().formData.sourceConnection).toEqual(newConnection);
|
||||
const form = wrapper.find("form");
|
||||
form.simulate("submit");
|
||||
expect(onSubmitHandler).toBeCalledWith({
|
||||
|
||||
const expectedProject = {
|
||||
...project,
|
||||
sourceConnection: connections[1],
|
||||
});
|
||||
};
|
||||
|
||||
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
|
||||
expect(onSubmitHandler).toBeCalledWith(expectedProject);
|
||||
});
|
||||
|
||||
it("starting project should update target connection ID upon submission", () => {
|
||||
@@ -125,13 +140,17 @@ describe("Project Form Component", () => {
|
||||
wrapper.find("select#root_targetConnection").simulate("change", { target: { value: newConnection.id } });
|
||||
expect(wrapper.state().formData.targetConnection).toEqual(newConnection);
|
||||
wrapper.find("form").simulate("submit");
|
||||
expect(onSubmitHandler).toBeCalledWith({
|
||||
|
||||
const expectedProject = {
|
||||
...project,
|
||||
targetConnection: connections[1],
|
||||
});
|
||||
};
|
||||
|
||||
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
|
||||
expect(onSubmitHandler).toBeCalledWith(expectedProject);
|
||||
});
|
||||
|
||||
it("starting project should call onChangeHandler on submission", () => {
|
||||
it("starting project should call onSubmitHandler on submission", () => {
|
||||
const form = wrapper.find("form");
|
||||
form.simulate("submit");
|
||||
expect(onSubmitHandler).toBeCalledWith({
|
||||
@@ -155,6 +174,7 @@ describe("Project Form Component", () => {
|
||||
|
||||
const form = wrapper.find("form");
|
||||
form.simulate("submit");
|
||||
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
|
||||
expect(onSubmitHandler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
name: newName,
|
||||
@@ -187,6 +207,7 @@ describe("Project Form Component", () => {
|
||||
appSettings,
|
||||
connections: newConnections,
|
||||
onSubmit: onSubmitHandler,
|
||||
onChange: onChangeHandler,
|
||||
onCancel: onCancelHandler,
|
||||
});
|
||||
// Source Connection should have all connections
|
||||
@@ -202,13 +223,12 @@ describe("Project Form Component", () => {
|
||||
|
||||
describe("Empty Project", () => {
|
||||
beforeEach(() => {
|
||||
onSubmitHandler = jest.fn();
|
||||
onCancelHandler = jest.fn();
|
||||
wrapper = createComponent({
|
||||
project: null,
|
||||
appSettings,
|
||||
connections,
|
||||
onSubmit: onSubmitHandler,
|
||||
onChange: onChangeHandler,
|
||||
onCancel: onCancelHandler,
|
||||
});
|
||||
});
|
||||
@@ -239,6 +259,7 @@ describe("Project Form Component", () => {
|
||||
appSettings,
|
||||
connections,
|
||||
onSubmit: onSubmitHandler,
|
||||
onChange: onChangeHandler,
|
||||
onCancel: onCancelHandler,
|
||||
});
|
||||
const newTagName = "My new tag";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import Form, { FormValidation, ISubmitEvent } from "react-jsonschema-form";
|
||||
import Form, { FormValidation, ISubmitEvent, IChangeEvent, Widget } from "react-jsonschema-form";
|
||||
import { ITagsInputProps, TagEditorModal, TagsInput } from "vott-react";
|
||||
import { addLocValues, strings } from "../../../../common/strings";
|
||||
import { IConnection, IProject, ITag, IAppSettings } from "../../../../models/applicationState";
|
||||
@@ -10,6 +10,7 @@ import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
|
||||
import { ISecurityTokenPickerProps, SecurityTokenPicker } from "../../common/securityTokenPicker/securityTokenPicker";
|
||||
import "vott-react/dist/css/tagsInput.css";
|
||||
import { IConnectionProviderPickerProps } from "../../common/connectionProviderPicker/connectionProviderPicker";
|
||||
import LocalFolderPicker from "../../common/localFolderPicker/localFolderPicker";
|
||||
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const formSchema = addLocValues(require("./projectForm.json"));
|
||||
@@ -28,6 +29,7 @@ export interface IProjectFormProps extends React.Props<ProjectForm> {
|
||||
connections: IConnection[];
|
||||
appSettings: IAppSettings;
|
||||
onSubmit: (project: IProject) => void;
|
||||
onChange?: (project: IProject) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
@@ -50,6 +52,10 @@ export interface IProjectFormState {
|
||||
* @description - Form for editing or creating VoTT projects
|
||||
*/
|
||||
export default class ProjectForm extends React.Component<IProjectFormProps, IProjectFormState> {
|
||||
private widgets = {
|
||||
localFolderPicker: (LocalFolderPicker as any) as Widget,
|
||||
};
|
||||
|
||||
private tagsInput: React.RefObject<TagsInput>;
|
||||
private tagEditorModal: React.RefObject<TagEditorModal>;
|
||||
|
||||
@@ -94,9 +100,11 @@ export default class ProjectForm extends React.Component<IProjectFormProps, IPro
|
||||
FieldTemplate={CustomFieldTemplate}
|
||||
validate={this.onFormValidate}
|
||||
fields={this.fields()}
|
||||
widgets={this.widgets}
|
||||
schema={this.state.formSchema}
|
||||
uiSchema={this.state.uiSchema}
|
||||
formData={this.state.formData}
|
||||
onChange={this.onFormChange}
|
||||
onSubmit={this.onFormSubmit}>
|
||||
<div>
|
||||
<button className="btn btn-success mr-1" type="submit">{strings.projectSettings.save}</button>
|
||||
@@ -184,6 +192,12 @@ export default class ProjectForm extends React.Component<IProjectFormProps, IPro
|
||||
return errors;
|
||||
}
|
||||
|
||||
private onFormChange = (changeEvent: IChangeEvent<IProject>) => {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(changeEvent.formData);
|
||||
}
|
||||
}
|
||||
|
||||
private onFormSubmit(args: ISubmitEvent<IProject>) {
|
||||
const project: IProject = {
|
||||
...args.formData,
|
||||
|
||||
@@ -4,20 +4,21 @@ import { Provider } from "react-redux";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
import createReduxStore from "../../../../redux/store/store";
|
||||
import ProjectSettingsPage, { IProjectSettingsPageProps } from "./projectSettingsPage";
|
||||
import ProjectSettingsPage, { IProjectSettingsPageProps, IProjectSettingsPageState } from "./projectSettingsPage";
|
||||
|
||||
jest.mock("../../../../services/projectService");
|
||||
import ProjectService from "../../../../services/projectService";
|
||||
import { IAppSettings } from "../../../../models/applicationState";
|
||||
import { IAppSettings, IProject } from "../../../../models/applicationState";
|
||||
import ProjectMetrics from "./projectMetrics";
|
||||
import ProjectForm, { IProjectFormProps } from "./projectForm";
|
||||
|
||||
jest.mock("./projectMetrics", () => () => {
|
||||
return (
|
||||
<div className="project-settings-page-metrics">
|
||||
Dummy Project Metrics
|
||||
</div>
|
||||
);
|
||||
},
|
||||
return (
|
||||
<div className="project-settings-page-metrics">
|
||||
Dummy Project Metrics
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe("Project settings page", () => {
|
||||
@@ -33,12 +34,29 @@ describe("Project settings page", () => {
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
|
||||
projectServiceMock.prototype.load = jest.fn((project) => ({...project}));
|
||||
const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(global, "_localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("Form submission calls save project action", (done) => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.getItem.mockClear();
|
||||
localStorageMock.setItem.mockClear();
|
||||
localStorageMock.removeItem.mockClear();
|
||||
|
||||
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
|
||||
projectServiceMock.prototype.load = jest.fn((project) => ({ ...project }));
|
||||
});
|
||||
|
||||
it("Form submission calls save project action", async () => {
|
||||
const store = createReduxStore(MockFactory.initialState());
|
||||
const props = MockFactory.projectSettingsProps();
|
||||
const saveProjectSpy = jest.spyOn(props.projectActions, "saveProject");
|
||||
@@ -47,14 +65,12 @@ describe("Project settings page", () => {
|
||||
|
||||
const wrapper = createComponent(store, props);
|
||||
wrapper.find("form").simulate("submit");
|
||||
await MockFactory.flushUi();
|
||||
|
||||
setImmediate(() => {
|
||||
expect(saveProjectSpy).toBeCalled();
|
||||
done();
|
||||
});
|
||||
expect(saveProjectSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it("Throws an error when a user tries to create a duplicate project", async (done) => {
|
||||
it("Throws an error when a user tries to create a duplicate project", async () => {
|
||||
const project = MockFactory.createTestProject("1");
|
||||
project.id = "25";
|
||||
const initialStateOverride = {
|
||||
@@ -78,18 +94,16 @@ describe("Project settings page", () => {
|
||||
},
|
||||
});
|
||||
wrapper.find("form").simulate("submit");
|
||||
setImmediate(async () => {
|
||||
// expect(saveProjectSpy).toBeCalled();
|
||||
expect(saveProjectSpy.mockRejectedValue).not.toBeNull();
|
||||
done();
|
||||
});
|
||||
await MockFactory.flushUi();
|
||||
|
||||
expect(saveProjectSpy.mockRejectedValue).not.toBeNull();
|
||||
});
|
||||
|
||||
it("calls save project when user creates a unique project", (done) => {
|
||||
it("calls save project when user creates a unique project", async () => {
|
||||
const initialState = MockFactory.initialState();
|
||||
|
||||
// New Project should not have id or security token set by default
|
||||
const project = {...initialState.recentProjects[0]};
|
||||
const project = { ...initialState.recentProjects[0] };
|
||||
project.id = null;
|
||||
project.name = "Brand New Project";
|
||||
project.securityToken = "";
|
||||
@@ -106,20 +120,20 @@ describe("Project settings page", () => {
|
||||
const wrapper = createComponent(store, props);
|
||||
wrapper.find("form").simulate("submit");
|
||||
|
||||
setImmediate(() => {
|
||||
// New security token was created for new project
|
||||
expect(saveAppSettingsSpy).toBeCalled();
|
||||
const appSettings = saveAppSettingsSpy.mock.calls[0][0] as IAppSettings;
|
||||
expect(appSettings.securityTokens.length).toEqual(initialState.appSettings.securityTokens.length + 1);
|
||||
await MockFactory.flushUi();
|
||||
|
||||
// New project was saved with new security token
|
||||
expect(saveProjectSpy).toBeCalledWith({
|
||||
...project,
|
||||
securityToken: `${project.name} Token`,
|
||||
});
|
||||
// New security token was created for new project
|
||||
expect(saveAppSettingsSpy).toBeCalled();
|
||||
const appSettings = saveAppSettingsSpy.mock.calls[0][0] as IAppSettings;
|
||||
expect(appSettings.securityTokens.length).toEqual(initialState.appSettings.securityTokens.length + 1);
|
||||
|
||||
done();
|
||||
// New project was saved with new security token
|
||||
expect(saveProjectSpy).toBeCalledWith({
|
||||
...project,
|
||||
securityToken: `${project.name} Token`,
|
||||
});
|
||||
|
||||
expect(localStorage.removeItem).toBeCalledWith("projectForm");
|
||||
});
|
||||
|
||||
it("render ProjectMetrics", () => {
|
||||
@@ -146,4 +160,64 @@ describe("Project settings page", () => {
|
||||
expect(projectMetrics).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Persisting project form", () => {
|
||||
let wrapper: ReactWrapper = null;
|
||||
|
||||
function initPersistProjectFormTest() {
|
||||
const store = createReduxStore(MockFactory.initialState());
|
||||
const props = MockFactory.projectSettingsProps();
|
||||
props.match.url = "/projects/create";
|
||||
wrapper = createComponent(store, props);
|
||||
}
|
||||
|
||||
it("Loads partial project from local storage", () => {
|
||||
const partialProject: IProject = {
|
||||
...{} as any,
|
||||
name: "partial project",
|
||||
description: "partial project description",
|
||||
tags: [
|
||||
{ name: "tag-1", color: "#ff0000" },
|
||||
{ name: "tag-3", color: "#ffff00" },
|
||||
],
|
||||
};
|
||||
|
||||
localStorageMock.getItem.mockImplementationOnce(() => JSON.stringify(partialProject));
|
||||
|
||||
initPersistProjectFormTest();
|
||||
const projectSettingsPage = wrapper
|
||||
.find(ProjectSettingsPage)
|
||||
.childAt(0) as ReactWrapper<IProjectSettingsPageProps, IProjectSettingsPageState>;
|
||||
|
||||
expect(localStorage.getItem).toBeCalledWith("projectForm");
|
||||
expect(projectSettingsPage.state().project).toEqual(partialProject);
|
||||
});
|
||||
|
||||
it("Stores partial project in local storage", () => {
|
||||
initPersistProjectFormTest();
|
||||
const partialProject: IProject = {
|
||||
...{} as any,
|
||||
name: "partial project",
|
||||
};
|
||||
|
||||
const projectForm = wrapper.find(ProjectForm) as ReactWrapper<IProjectFormProps>;
|
||||
projectForm.props().onChange(partialProject);
|
||||
|
||||
expect(localStorage.setItem).toBeCalledWith("projectForm", JSON.stringify(partialProject));
|
||||
});
|
||||
|
||||
it("Does NOT store empty project in local storage", () => {
|
||||
initPersistProjectFormTest();
|
||||
const emptyProject: IProject = {
|
||||
...{} as any,
|
||||
sourceConnection: {},
|
||||
targetConnection: {},
|
||||
exportFormat: {},
|
||||
};
|
||||
const projectForm = wrapper.find(ProjectForm) as ReactWrapper<IProjectFormProps>;
|
||||
projectForm.props().onChange(emptyProject);
|
||||
|
||||
expect(localStorage.setItem).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface IProjectSettingsPageProps extends RouteComponentProps, React.Pr
|
||||
appSettings: IAppSettings;
|
||||
}
|
||||
|
||||
export interface IProjectSettingsPageState {
|
||||
project: IProject;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IApplicationState) {
|
||||
return {
|
||||
project: state.currentProject,
|
||||
@@ -43,24 +47,40 @@ function mapDispatchToProps(dispatch) {
|
||||
};
|
||||
}
|
||||
|
||||
const projectFormTempKey = "projectForm";
|
||||
|
||||
/**
|
||||
* @name - Project Settings Page
|
||||
* @description - Page for adding/editing/removing projects
|
||||
*/
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class ProjectSettingsPage extends React.Component<IProjectSettingsPageProps> {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
export default class ProjectSettingsPage extends React.Component<IProjectSettingsPageProps, IProjectSettingsPageState> {
|
||||
public state: IProjectSettingsPageState = {
|
||||
project: this.props.project,
|
||||
};
|
||||
|
||||
public async componentDidMount() {
|
||||
const projectId = this.props.match.params["projectId"];
|
||||
if (!this.props.project && projectId) {
|
||||
const project = this.props.recentProjects.find((project) => project.id === projectId);
|
||||
this.props.applicationActions.ensureSecurityToken(project);
|
||||
this.props.projectActions.loadProject(project);
|
||||
// If we are creating a new project check to see if there is a partial
|
||||
// project already created in local storage
|
||||
if (this.props.match.url === "/projects/create") {
|
||||
const projectJson = localStorage.getItem(projectFormTempKey);
|
||||
if (projectJson) {
|
||||
this.setState({ project: JSON.parse(projectJson) });
|
||||
}
|
||||
} else if (!this.props.project && projectId) {
|
||||
const projectToLoad = this.props.recentProjects.find((project) => project.id === projectId);
|
||||
if (projectToLoad) {
|
||||
await this.props.applicationActions.ensureSecurityToken(projectToLoad);
|
||||
await this.props.projectActions.loadProject(projectToLoad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.onFormSubmit = this.onFormSubmit.bind(this);
|
||||
this.onFormCancel = this.onFormCancel.bind(this);
|
||||
public componentDidUpdate(prevProps: Readonly<IProjectSettingsPageProps>) {
|
||||
if (prevProps.project !== this.props.project) {
|
||||
this.setState({ project: this.props.project });
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
@@ -75,9 +95,10 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
|
||||
</h3>
|
||||
<div className="m-3">
|
||||
<ProjectForm
|
||||
project={this.props.project}
|
||||
project={this.state.project}
|
||||
connections={this.props.connections}
|
||||
appSettings={this.props.appSettings}
|
||||
onChange={this.onFormChange}
|
||||
onSubmit={this.onFormSubmit}
|
||||
onCancel={this.onFormCancel} />
|
||||
</div>
|
||||
@@ -91,11 +112,23 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the project form is changed verifies if the project contains enough information
|
||||
* to persist into temp local storage to support better new project flow when
|
||||
* creating new connections inline
|
||||
*/
|
||||
private onFormChange = (project: IProject) => {
|
||||
if (this.isPartialProject(project)) {
|
||||
localStorage.setItem(projectFormTempKey, JSON.stringify(project));
|
||||
}
|
||||
}
|
||||
|
||||
private onFormSubmit = async (project: IProject) => {
|
||||
const isNew = !(!!project.id);
|
||||
|
||||
await this.props.applicationActions.ensureSecurityToken(project);
|
||||
await this.props.projectActions.saveProject(project);
|
||||
localStorage.removeItem(projectFormTempKey);
|
||||
|
||||
toast.success(interpolate(strings.projectSettings.messages.saveSuccess, { project }));
|
||||
|
||||
@@ -106,7 +139,23 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
|
||||
}
|
||||
}
|
||||
|
||||
private onFormCancel() {
|
||||
private onFormCancel = () => {
|
||||
localStorage.removeItem(projectFormTempKey);
|
||||
this.props.history.goBack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a project is partially populated
|
||||
*/
|
||||
private isPartialProject = (project: IProject): boolean => {
|
||||
return project && !(!!project.id) &&
|
||||
(
|
||||
!!project.name
|
||||
|| !!project.description
|
||||
|| (project.sourceConnection && Object.keys(project.sourceConnection).length > 0)
|
||||
|| (project.targetConnection && Object.keys(project.targetConnection).length > 0)
|
||||
|| (project.exportFormat && Object.keys(project.exportFormat).length > 0)
|
||||
|| (project.tags && project.tags.length > 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import MainContentRouter from "./mainContentRouter";
|
||||
import HomePage, { IHomePageProps } from "./../pages/homepage/homePage";
|
||||
import SettingsPage from "./../pages/appSettings/appSettingsPage";
|
||||
import ConnectionsPage from "./../pages/connections/connectionsPage";
|
||||
import ProfilePage from "./../pages/profileSettingsPage";
|
||||
import { IApplicationState } from "./../../../models/applicationState";
|
||||
|
||||
describe("Main Content Router", () => {
|
||||
@@ -43,7 +42,6 @@ describe("Main Content Router", () => {
|
||||
expect(pathMap["/"]).toBe(HomePage);
|
||||
expect(pathMap["/settings"]).toBe(SettingsPage);
|
||||
expect(pathMap["/connections"]).toBe(ConnectionsPage);
|
||||
expect(pathMap["/profile"]).toBe(ProfilePage);
|
||||
});
|
||||
|
||||
it("renders a redirect when no route is matched", () => {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from "react";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import HomePage from "../pages/homepage/homePage";
|
||||
import ActiveLearningPage from "../pages/activeLearningPage";
|
||||
import ActiveLearningPage from "../pages/activeLearning/activeLearningPage";
|
||||
import AppSettingsPage from "../pages/appSettings/appSettingsPage";
|
||||
import ConnectionPage from "../pages/connections/connectionsPage";
|
||||
import EditorPage from "../pages/editorPage/editorPage";
|
||||
import ExportPage from "../pages/export/exportPage";
|
||||
import ProjectSettingsPage from "../pages/projectSettings/projectSettingsPage";
|
||||
import ProfileSettingsPage from "../pages/profileSettingsPage";
|
||||
|
||||
/**
|
||||
* @name - Main Content Router
|
||||
@@ -19,7 +18,6 @@ export default function MainContentRouter() {
|
||||
<Switch>
|
||||
<Route path="/" exact component={HomePage} />
|
||||
<Route path="/settings" component={AppSettingsPage} />
|
||||
<Route path="/profile" component={ProfileSettingsPage} />
|
||||
<Route path="/connections/:connectionId" component={ConnectionPage} />
|
||||
<Route path="/connections" exact component={ConnectionPage} />
|
||||
<Route path="/projects/:projectId/edit" component={EditorPage} />
|
||||
|
||||
@@ -16,6 +16,6 @@ describe("Sidebar Component", () => {
|
||||
expect(wrapper).not.toBeNull();
|
||||
|
||||
const links = wrapper.find("ul li");
|
||||
expect(links.length).toEqual(6);
|
||||
expect(links.length).toEqual(7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,16 @@ export default function Sidebar({ project }) {
|
||||
<ConditionalNavLink disabled={!projectId}
|
||||
title={strings.export.title}
|
||||
to={`/projects/${projectId}/export`}>
|
||||
<i className="fas fa-external-link-square-alt"></i></ConditionalNavLink></li>
|
||||
<i className="fas fa-external-link-square-alt"></i>
|
||||
</ConditionalNavLink>
|
||||
</li>
|
||||
<li>
|
||||
<ConditionalNavLink disabled={!projectId}
|
||||
title={strings.activeLearning.title}
|
||||
to={`/projects/${projectId}/active-learning`}>
|
||||
<i className="fas fa-graduation-cap"></i>
|
||||
</ConditionalNavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink title={strings.connections.title}
|
||||
to={`/connections`}><i className="fas fa-plug"></i></NavLink>
|
||||
|
||||
@@ -16,6 +16,8 @@ export enum ActionTypes {
|
||||
CLOSE_PROJECT_SUCCESS = "CLOSE_PROJECT_SUCCESS",
|
||||
LOAD_PROJECT_ASSETS_SUCCESS = "LOAD_PROJECT_ASSETS_SUCCESS",
|
||||
EXPORT_PROJECT_SUCCESS = "EXPORT_PROJECT_SUCCESS",
|
||||
UPDATE_PROJECT_TAG_SUCCESS = "UPDATE_PROJECT_TAG_SUCCESS",
|
||||
DELETE_PROJECT_TAG_SUCCESS = "DELETE_PROJECT_TAG_SUCCESS",
|
||||
|
||||
// Connections
|
||||
LOAD_CONNECTION_SUCCESS = "LOAD_CONNECTION_SUCCESS",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import _ from "lodash";
|
||||
import createMockStore, { MockStoreEnhanced } from "redux-mock-store";
|
||||
import { ActionTypes } from "./actionTypes";
|
||||
import * as projectActions from "./projectActions";
|
||||
@@ -10,25 +11,30 @@ import ProjectService from "../../services/projectService";
|
||||
jest.mock("../../services/assetService");
|
||||
import { AssetService } from "../../services/assetService";
|
||||
import { ExportProviderFactory } from "../../providers/export/exportProviderFactory";
|
||||
import { ExportAssetState, IExportProvider } from "../../providers/export/exportProvider";
|
||||
import { IApplicationState } from "../../models/applicationState";
|
||||
import { IExportProvider } from "../../providers/export/exportProvider";
|
||||
import { IApplicationState, IProject } from "../../models/applicationState";
|
||||
import initialState from "../store/initialState";
|
||||
import { appInfo } from "../../common/appInfo";
|
||||
import registerMixins from "../../registerMixins";
|
||||
|
||||
describe("Project Redux Actions", () => {
|
||||
let store: MockStoreEnhanced<IApplicationState>;
|
||||
let projectServiceMock: jest.Mocked<typeof ProjectService>;
|
||||
const appSettings = MockFactory.appSettings();
|
||||
|
||||
beforeAll(registerMixins);
|
||||
|
||||
beforeEach(() => {
|
||||
const middleware = [thunk];
|
||||
const mockState: IApplicationState = {
|
||||
...initialState,
|
||||
appSettings,
|
||||
};
|
||||
|
||||
store = createMockStore<IApplicationState>(middleware)(mockState);
|
||||
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
|
||||
projectServiceMock.prototype.load = jest.fn((project) => Promise.resolve(project));
|
||||
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
|
||||
});
|
||||
|
||||
it("Load Project action resolves a promise and dispatches redux action", async () => {
|
||||
@@ -81,39 +87,6 @@ describe("Project Redux Actions", () => {
|
||||
expect(result.version).toEqual(appInfo.version);
|
||||
});
|
||||
|
||||
it("Save Project action on new project correctly add default export format", async () => {
|
||||
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
|
||||
|
||||
const skeletonProject = MockFactory.createTestProject("TestProject");
|
||||
const project = {
|
||||
...skeletonProject,
|
||||
exportFormat: null,
|
||||
};
|
||||
|
||||
const result = await projectActions.saveProject(project)(store.dispatch, store.getState);
|
||||
|
||||
expect(result.exportFormat).toEqual({
|
||||
providerType: "vottJson",
|
||||
providerOptions: {
|
||||
assetState: ExportAssetState.Visited,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("Save Project action on new project correctly set tags to empty if none created", async () => {
|
||||
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
|
||||
|
||||
const skeletonProject = MockFactory.createTestProject("TestProject");
|
||||
const project = {
|
||||
...skeletonProject,
|
||||
tags: null,
|
||||
};
|
||||
|
||||
const result = await projectActions.saveProject(project)(store.dispatch, store.getState);
|
||||
|
||||
expect(result.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it("Save Project action does not override existing export format", async () => {
|
||||
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
|
||||
|
||||
@@ -254,4 +227,81 @@ describe("Project Redux Actions", () => {
|
||||
|
||||
expect(mockExportProvider.export).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Updating project tags", () => {
|
||||
let project: IProject = null;
|
||||
|
||||
beforeEach(() => {
|
||||
project = MockFactory.createTestProject("TestProject");
|
||||
const middleware = [thunk];
|
||||
const mockState: IApplicationState = {
|
||||
...initialState,
|
||||
currentProject: project,
|
||||
appSettings,
|
||||
};
|
||||
|
||||
store = createMockStore<IApplicationState>(middleware)(mockState);
|
||||
});
|
||||
|
||||
it("Updates tags across all project assets when a tag is renamed", async () => {
|
||||
const projectAssets = _.values(project.assets);
|
||||
const updatedTag = project.tags[project.tags.length - 1];
|
||||
|
||||
const updatedAssets = [
|
||||
MockFactory.createTestAssetMetadata(projectAssets[0]),
|
||||
MockFactory.createTestAssetMetadata(projectAssets[1]),
|
||||
];
|
||||
|
||||
const expectedTagName = `${updatedTag.name} - updated`;
|
||||
|
||||
const assetServiceMock = AssetService as jest.Mocked<typeof AssetService>;
|
||||
assetServiceMock.prototype.renameTag = jest.fn(() => Promise.resolve(updatedAssets));
|
||||
|
||||
const actualUpdatedAssets = await projectActions.updateProjectTag(
|
||||
project,
|
||||
updatedTag.name,
|
||||
expectedTagName,
|
||||
)(store.dispatch, store.getState);
|
||||
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions.length).toEqual(5);
|
||||
expect(actions[0].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS);
|
||||
expect(actions[1].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS);
|
||||
expect(actions[2].type).toEqual(ActionTypes.SAVE_PROJECT_SUCCESS);
|
||||
expect(actions[3].type).toEqual(ActionTypes.LOAD_PROJECT_SUCCESS);
|
||||
expect(actions[4].type).toEqual(ActionTypes.UPDATE_PROJECT_TAG_SUCCESS);
|
||||
|
||||
expect(actualUpdatedAssets).toEqual(updatedAssets);
|
||||
});
|
||||
|
||||
it("Deletes tags across all project assets when a tag is renamed", async () => {
|
||||
const projectAssets = _.values(project.assets);
|
||||
const deletedTag = project.tags[project.tags.length - 1];
|
||||
|
||||
const updatedAssets = [
|
||||
MockFactory.createTestAssetMetadata(projectAssets[0]),
|
||||
MockFactory.createTestAssetMetadata(projectAssets[1]),
|
||||
];
|
||||
|
||||
const assetServiceMock = AssetService as jest.Mocked<typeof AssetService>;
|
||||
assetServiceMock.prototype.deleteTag = jest.fn(() => Promise.resolve(updatedAssets));
|
||||
|
||||
const actualUpdatedAssets = await projectActions.deleteProjectTag(
|
||||
project,
|
||||
deletedTag.name,
|
||||
)(store.dispatch, store.getState);
|
||||
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions.length).toEqual(5);
|
||||
expect(actions[0].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS);
|
||||
expect(actions[1].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS);
|
||||
expect(actions[2].type).toEqual(ActionTypes.SAVE_PROJECT_SUCCESS);
|
||||
expect(actions[3].type).toEqual(ActionTypes.LOAD_PROJECT_SUCCESS);
|
||||
expect(actions[4].type).toEqual(ActionTypes.DELETE_PROJECT_TAG_SUCCESS);
|
||||
|
||||
expect(actualUpdatedAssets).toEqual(updatedAssets);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { createAction, createPayloadAction, IPayloadAction } from "./actionCreat
|
||||
import { ExportAssetState, IExportResults } from "../../providers/export/exportProvider";
|
||||
import { appInfo } from "../../common/appInfo";
|
||||
import { strings } from "../../common/strings";
|
||||
import { IExportFormat } from "vott-react";
|
||||
import { IVottJsonExportProviderOptions } from "../../providers/export/vottJson";
|
||||
|
||||
/**
|
||||
* Actions to be performed in relation to projects
|
||||
@@ -28,6 +30,8 @@ export default interface IProjectActions {
|
||||
loadAssets(project: IProject): Promise<IAsset[]>;
|
||||
loadAssetMetadata(project: IProject, asset: IAsset): Promise<IAssetMetadata>;
|
||||
saveAssetMetadata(project: IProject, assetMetadata: IAssetMetadata): Promise<IAssetMetadata>;
|
||||
updateProjectTag(project: IProject, oldTagName: string, newTagName: string): Promise<IAssetMetadata[]>;
|
||||
deleteProjectTag(project: IProject, tagName): Promise<IAssetMetadata[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,21 +80,7 @@ export function saveProject(project: IProject)
|
||||
throw new AppError(ErrorCode.SecurityTokenNotFound, "Security Token Not Found");
|
||||
}
|
||||
|
||||
const defaultExportFormat = {
|
||||
providerType: "vottJson",
|
||||
providerOptions: {
|
||||
assetState: ExportAssetState.Visited,
|
||||
},
|
||||
};
|
||||
|
||||
const newProject = {
|
||||
...project,
|
||||
version: appInfo.version,
|
||||
exportFormat: project.exportFormat || defaultExportFormat,
|
||||
tags: project.tags || [],
|
||||
};
|
||||
|
||||
const savedProject = await projectService.save(newProject, projectToken);
|
||||
const savedProject = await projectService.save(project, projectToken);
|
||||
dispatch(saveProjectAction(savedProject));
|
||||
|
||||
// Reload project after save actions
|
||||
@@ -130,7 +120,7 @@ export function deleteProject(project: IProject)
|
||||
*/
|
||||
export function closeProject(): (dispatch: Dispatch) => void {
|
||||
return (dispatch: Dispatch): void => {
|
||||
dispatch({type: ActionTypes.CLOSE_PROJECT_SUCCESS});
|
||||
dispatch({ type: ActionTypes.CLOSE_PROJECT_SUCCESS });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,7 +149,7 @@ export function loadAssetMetadata(project: IProject, asset: IAsset): (dispatch:
|
||||
const assetMetadata = await assetService.getAssetMetadata(asset);
|
||||
dispatch(loadAssetMetadataAction(assetMetadata));
|
||||
|
||||
return {...assetMetadata};
|
||||
return { ...assetMetadata };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,14 +161,77 @@ export function loadAssetMetadata(project: IProject, asset: IAsset): (dispatch:
|
||||
export function saveAssetMetadata(
|
||||
project: IProject,
|
||||
assetMetadata: IAssetMetadata): (dispatch: Dispatch) => Promise<IAssetMetadata> {
|
||||
const newAssetMetadata = {...assetMetadata, version: appInfo.version};
|
||||
const newAssetMetadata = { ...assetMetadata, version: appInfo.version };
|
||||
|
||||
return async (dispatch: Dispatch) => {
|
||||
const assetService = new AssetService(project);
|
||||
const savedMetadata = await assetService.save(newAssetMetadata);
|
||||
dispatch(saveAssetMetadataAction(savedMetadata));
|
||||
|
||||
return {...savedMetadata};
|
||||
return { ...savedMetadata };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a project and all asset references from oldTagName to newTagName
|
||||
* @param project The project to update tags
|
||||
* @param oldTagName The old tag name
|
||||
* @param newTagName The new tag name
|
||||
*/
|
||||
export function updateProjectTag(project: IProject, oldTagName: string, newTagName: string)
|
||||
: (dispatch: Dispatch, getState: () => IApplicationState) => Promise<IAssetMetadata[]> {
|
||||
return async (dispatch: Dispatch, getState: () => IApplicationState) => {
|
||||
// Find tags to rename
|
||||
const assetService = new AssetService(project);
|
||||
const assetUpdates = await assetService.renameTag(oldTagName, newTagName);
|
||||
|
||||
// Save updated assets
|
||||
await assetUpdates.forEachAsync(async (assetMetadata) => {
|
||||
await saveAssetMetadata(project, assetMetadata)(dispatch);
|
||||
});
|
||||
|
||||
const currentProject = getState().currentProject;
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
tags: project.tags.map((t) => (t.name === oldTagName) ? { ...t, name: newTagName } : t),
|
||||
};
|
||||
|
||||
// Save updated project tags
|
||||
await saveProject(updatedProject)(dispatch, getState);
|
||||
dispatch(updateProjectTagAction(updatedProject));
|
||||
|
||||
return assetUpdates;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a project and all asset references from oldTagName to newTagName
|
||||
* @param project The project to delete tags
|
||||
* @param tagName The tag to delete
|
||||
*/
|
||||
export function deleteProjectTag(project: IProject, tagName)
|
||||
: (dispatch: Dispatch, getState: () => IApplicationState) => Promise<IAssetMetadata[]> {
|
||||
return async (dispatch: Dispatch, getState: () => IApplicationState) => {
|
||||
// Find tags to rename
|
||||
const assetService = new AssetService(project);
|
||||
const assetUpdates = await assetService.deleteTag(tagName);
|
||||
|
||||
// Save updated assets
|
||||
await assetUpdates.forEachAsync(async (assetMetadata) => {
|
||||
await saveAssetMetadata(project, assetMetadata)(dispatch);
|
||||
});
|
||||
|
||||
const currentProject = getState().currentProject;
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
tags: project.tags.filter((t) => t.name !== tagName),
|
||||
};
|
||||
|
||||
// Save updated project tags
|
||||
await saveProject(updatedProject)(dispatch, getState);
|
||||
dispatch(deleteProjectTagAction(updatedProject));
|
||||
|
||||
return assetUpdates;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,6 +315,20 @@ export interface IExportProjectAction extends IPayloadAction<string, IProject> {
|
||||
type: ActionTypes.EXPORT_PROJECT_SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Project Tag action type
|
||||
*/
|
||||
export interface IUpdateProjectTagAction extends IPayloadAction<string, IProject> {
|
||||
type: ActionTypes.UPDATE_PROJECT_TAG_SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete project tag action type
|
||||
*/
|
||||
export interface IDeleteProjectTagAction extends IPayloadAction<string, IProject> {
|
||||
type: ActionTypes.DELETE_PROJECT_TAG_SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance of Load Project action
|
||||
*/
|
||||
@@ -298,3 +365,13 @@ export const saveAssetMetadataAction =
|
||||
*/
|
||||
export const exportProjectAction =
|
||||
createPayloadAction<IExportProjectAction>(ActionTypes.EXPORT_PROJECT_SUCCESS);
|
||||
/**
|
||||
* Instance of Update project tag action
|
||||
*/
|
||||
export const updateProjectTagAction =
|
||||
createPayloadAction<IUpdateProjectTagAction>(ActionTypes.UPDATE_PROJECT_TAG_SUCCESS);
|
||||
/**
|
||||
* Instance of Delete project tag action
|
||||
*/
|
||||
export const deleteProjectTagAction =
|
||||
createPayloadAction<IDeleteProjectTagAction>(ActionTypes.DELETE_PROJECT_TAG_SUCCESS);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import _ from "lodash";
|
||||
import { reducer } from "./currentProjectReducer";
|
||||
import { IProject, IAssetMetadata, AssetState } from "../../models/applicationState";
|
||||
import { IProject, IAssetMetadata, AssetState, ITag } from "../../models/applicationState";
|
||||
import MockFactory from "../../common/mockFactory";
|
||||
import {
|
||||
loadProjectAction,
|
||||
@@ -50,7 +50,7 @@ describe("Current Project Reducer", () => {
|
||||
expect(result).toEqual(currentProject);
|
||||
});
|
||||
|
||||
it("Updating connection used by current project is updated in curren project", () => {
|
||||
it("Updating connection used by current project is updated in current project", () => {
|
||||
const currentProject = MockFactory.createTestProject("1");
|
||||
const state: IProject = currentProject;
|
||||
|
||||
@@ -113,6 +113,29 @@ describe("Current Project Reducer", () => {
|
||||
expect(result.assets[testAssets[0].id]).toEqual(assetMetadata.asset);
|
||||
});
|
||||
|
||||
it("Appends new tags to project when saving asset contains new tags", () => {
|
||||
const state: IProject = MockFactory.createTestProject("TestProject");
|
||||
const testAssets = MockFactory.createTestAssets();
|
||||
|
||||
const expectedTag: ITag = {
|
||||
name: "NEWTAG",
|
||||
color: expect.any(String),
|
||||
};
|
||||
|
||||
const assetMetadata = MockFactory.createTestAssetMetadata(
|
||||
testAssets[0],
|
||||
[MockFactory.createTestRegion("Region 1", [expectedTag.name])],
|
||||
);
|
||||
|
||||
const action = saveAssetMetadataAction(assetMetadata);
|
||||
const result = reducer(state, action);
|
||||
expect(result).not.toBe(state);
|
||||
expect(result.tags).toEqual([
|
||||
...state.tags,
|
||||
expectedTag,
|
||||
]);
|
||||
});
|
||||
|
||||
it("Unknown action performs a noop", () => {
|
||||
const state: IProject = MockFactory.createTestProject("TestProject");
|
||||
const action = anyOtherAction();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import _ from "lodash";
|
||||
import { ActionTypes } from "../actions/actionTypes";
|
||||
import { IProject } from "../../models/applicationState";
|
||||
import { IProject, ITag } from "../../models/applicationState";
|
||||
import { AnyAction } from "../actions/actionCreators";
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const tagColors = require("../../react/components/common/tagColors.json");
|
||||
|
||||
/**
|
||||
* Reducer for project. Actions handled:
|
||||
@@ -38,6 +40,31 @@ export const reducer = (state: IProject = null, action: AnyAction): IProject =>
|
||||
const updatedAssets = { ...state.assets } || {};
|
||||
updatedAssets[action.payload.asset.id] = { ...action.payload.asset };
|
||||
|
||||
const assetTags = new Set();
|
||||
action.payload.regions.forEach((region) => region.tags.forEach((tag) => assetTags.add(tag)));
|
||||
|
||||
const newTags: ITag[] = state.tags ? [...state.tags] : [];
|
||||
let updateTags = false;
|
||||
|
||||
assetTags.forEach((tag) => {
|
||||
if (!state.tags || state.tags.length === 0 ||
|
||||
!state.tags.find((projectTag) => tag === projectTag.name)) {
|
||||
newTags.push({
|
||||
name: tag,
|
||||
color: tagColors[newTags.length % tagColors.length],
|
||||
});
|
||||
updateTags = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (updateTags) {
|
||||
return {
|
||||
...state,
|
||||
tags: newTags,
|
||||
assets: updatedAssets,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
assets: updatedAssets,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ExportProviderFactory } from "./providers/export/exportProviderFactory";
|
||||
import { TFPascalVOCExportProvider } from "./providers/export/tensorFlowPascalVOC";
|
||||
import { PascalVOCExportProvider } from "./providers/export/pascalVOC";
|
||||
import { TFRecordsExportProvider } from "./providers/export/tensorFlowRecords";
|
||||
import { VottJsonExportProvider } from "./providers/export/vottJson";
|
||||
import { CsvExportProvider } from "./providers/export/csv";
|
||||
import { AssetProviderFactory } from "./providers/storage/assetProviderFactory";
|
||||
import { AzureBlobStorage } from "./providers/storage/azureBlobStorage";
|
||||
import { BingImageSearch } from "./providers/storage/bingImageSearch";
|
||||
@@ -11,6 +12,7 @@ import registerToolbar from "./registerToolbar";
|
||||
import { strings } from "./common/strings";
|
||||
import { HostProcessType } from "./common/hostProcess";
|
||||
import { AzureCustomVisionProvider } from "./providers/export/azureCustomVision";
|
||||
import { CntkExportProvider } from "./providers/export/cntk";
|
||||
|
||||
/**
|
||||
* Registers storage, asset and export providers, as well as all toolbar items
|
||||
@@ -54,9 +56,9 @@ export default function registerProviders() {
|
||||
factory: (project, options) => new VottJsonExportProvider(project, options),
|
||||
});
|
||||
ExportProviderFactory.register({
|
||||
name: "tensorFlowPascalVOC",
|
||||
displayName: strings.export.providers.tfPascalVoc.displayName,
|
||||
factory: (project, options) => new TFPascalVOCExportProvider(project, options),
|
||||
name: "pascalVOC",
|
||||
displayName: strings.export.providers.pascalVoc.displayName,
|
||||
factory: (project, options) => new PascalVOCExportProvider(project, options),
|
||||
});
|
||||
ExportProviderFactory.register({
|
||||
name: "tensorFlowRecords",
|
||||
@@ -68,6 +70,16 @@ export default function registerProviders() {
|
||||
displayName: strings.export.providers.azureCV.displayName,
|
||||
factory: (project, options) => new AzureCustomVisionProvider(project, options),
|
||||
});
|
||||
ExportProviderFactory.register({
|
||||
name: "cntk",
|
||||
displayName: strings.export.providers.cntk.displayName,
|
||||
factory: (project, options) => new CntkExportProvider(project, options),
|
||||
});
|
||||
ExportProviderFactory.register({
|
||||
name: "csv",
|
||||
displayName: strings.export.providers.csv.displayName,
|
||||
factory: (project, options) => new CsvExportProvider(project, options),
|
||||
});
|
||||
|
||||
registerToolbar();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export enum ToolbarItemName {
|
||||
NextAsset = "navigateNextAsset",
|
||||
SaveProject = "saveProject",
|
||||
ExportProject = "exportProject",
|
||||
ActiveLearning = "activeLearning",
|
||||
}
|
||||
|
||||
export enum ToolbarItemGroup {
|
||||
@@ -102,6 +103,15 @@ export default function registerToolbar() {
|
||||
accelerators: ["CmdOrCtrl+Delete", "CmdOrCtrl+Backspace"],
|
||||
});
|
||||
|
||||
ToolbarItemFactory.register({
|
||||
name: ToolbarItemName.ActiveLearning,
|
||||
tooltip: strings.editorPage.toolbar.activeLearning,
|
||||
icon: "fas fa-graduation-cap",
|
||||
group: ToolbarItemGroup.Canvas,
|
||||
type: ToolbarItemType.Action,
|
||||
accelerators: ["CmdOrCtrl+D", "CmdOrCtrl+d"],
|
||||
});
|
||||
|
||||
ToolbarItemFactory.register({
|
||||
name: ToolbarItemName.PreviousAsset,
|
||||
tooltip: strings.editorPage.toolbar.previousAsset,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ActiveLearningService } from "./activeLearningService";
|
||||
import { IActiveLearningSettings, ModelPathType, IAssetMetadata, AssetState } from "../models/applicationState";
|
||||
import MockFactory from "../common/mockFactory";
|
||||
import { appInfo } from "../common/appInfo";
|
||||
import { ObjectDetection } from "../providers/activeLearning/objectDetection";
|
||||
|
||||
describe("Active Learning Service", () => {
|
||||
const objectDetectionMock = ObjectDetection as jest.Mocked<typeof ObjectDetection>;
|
||||
const defaultSettings: IActiveLearningSettings = {
|
||||
modelPathType: ModelPathType.Coco,
|
||||
autoDetect: true,
|
||||
predictTag: true,
|
||||
};
|
||||
|
||||
let activeLearningService: ActiveLearningService = null;
|
||||
|
||||
const electronMock = {
|
||||
remote: {
|
||||
app: {
|
||||
getAppPath: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
window["require"] = jest.fn(() => electronMock);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
activeLearningService = new ActiveLearningService(defaultSettings);
|
||||
objectDetectionMock.prototype.load = jest.fn(() => Promise.resolve());
|
||||
objectDetectionMock.prototype.predictImage = jest.fn(() => Promise.resolve([]));
|
||||
});
|
||||
|
||||
it("Predicts new regions to the asset metadata", async () => {
|
||||
objectDetectionMock.prototype.predictImage = jest.fn(() => Promise.resolve(expectedRegions));
|
||||
|
||||
const expectedRegions = MockFactory.createTestRegions(2);
|
||||
const canvas = MockFactory.mockCanvas()();
|
||||
const asset = MockFactory.createTestAsset("TestAsset", AssetState.Visited);
|
||||
const assetMetadata: IAssetMetadata = {
|
||||
asset: {
|
||||
...asset,
|
||||
state: AssetState.Tagged,
|
||||
},
|
||||
regions: [],
|
||||
version: appInfo.version,
|
||||
};
|
||||
|
||||
const updatedAssetMetadata = await activeLearningService.predictRegions(canvas, assetMetadata);
|
||||
|
||||
expect(updatedAssetMetadata).toEqual({
|
||||
asset: {
|
||||
...assetMetadata.asset,
|
||||
predicted: true,
|
||||
},
|
||||
regions: expectedRegions,
|
||||
version: appInfo.version,
|
||||
});
|
||||
});
|
||||
|
||||
it("Predicts non matching regions to the asset metadata", async () => {
|
||||
objectDetectionMock.prototype.predictImage = jest.fn(() => Promise.resolve(expectedRegions));
|
||||
|
||||
const uniqueRegion = MockFactory.createTestRegion("UniqueRegion", ["tag1", "tag2"]);
|
||||
const expectedRegions = MockFactory.createTestRegions(4);
|
||||
const canvas = MockFactory.mockCanvas()();
|
||||
const asset = MockFactory.createTestAsset("TestAsset", AssetState.Visited);
|
||||
const assetMetadata: IAssetMetadata = {
|
||||
asset: {
|
||||
...asset,
|
||||
state: AssetState.Tagged,
|
||||
},
|
||||
regions: [
|
||||
uniqueRegion,
|
||||
expectedRegions[0],
|
||||
expectedRegions[1],
|
||||
],
|
||||
version: appInfo.version,
|
||||
};
|
||||
|
||||
const updatedAssetMetadata = await activeLearningService.predictRegions(canvas, assetMetadata);
|
||||
|
||||
expect(updatedAssetMetadata).toEqual({
|
||||
asset: {
|
||||
...assetMetadata.asset,
|
||||
predicted: true,
|
||||
},
|
||||
regions: [
|
||||
uniqueRegion,
|
||||
...expectedRegions,
|
||||
],
|
||||
version: appInfo.version,
|
||||
});
|
||||
});
|
||||
|
||||
it("ensures the underlying object detection model is only loaded 1 time", async () => {
|
||||
const canvas = MockFactory.mockCanvas()();
|
||||
const assetMetadata: IAssetMetadata = {
|
||||
asset: MockFactory.createTestAsset("TestAsset", AssetState.Visited),
|
||||
regions: [],
|
||||
version: appInfo.version,
|
||||
};
|
||||
|
||||
await activeLearningService.predictRegions(canvas, assetMetadata);
|
||||
await activeLearningService.predictRegions(canvas, assetMetadata);
|
||||
await activeLearningService.predictRegions(canvas, assetMetadata);
|
||||
await activeLearningService.predictRegions(canvas, assetMetadata);
|
||||
expect(objectDetectionMock.prototype.load).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails if constructor requirements aren't satisfied", () => {
|
||||
expect(() => new ActiveLearningService(null)).toThrow();
|
||||
});
|
||||
|
||||
it("fails if method requirements aren't satisfied", () => {
|
||||
const service = new ActiveLearningService(defaultSettings);
|
||||
expect(service.predictRegions(null, null)).rejects.not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { IAssetMetadata, ModelPathType, IActiveLearningSettings, AssetState } from "../models/applicationState";
|
||||
import { ObjectDetection } from "../providers/activeLearning/objectDetection";
|
||||
import Guard from "../common/guard";
|
||||
import { isElectron } from "../common/hostProcess";
|
||||
import { Env } from "../common/environment";
|
||||
|
||||
export class ActiveLearningService {
|
||||
private objectDetection: ObjectDetection;
|
||||
private modelLoaded: boolean = false;
|
||||
|
||||
constructor(private settings: IActiveLearningSettings) {
|
||||
Guard.null(settings);
|
||||
this.objectDetection = new ObjectDetection();
|
||||
}
|
||||
|
||||
public isModelLoaded() {
|
||||
return this.modelLoaded;
|
||||
}
|
||||
|
||||
public async predictRegions(canvas: HTMLCanvasElement, assetMetadata: IAssetMetadata): Promise<IAssetMetadata> {
|
||||
Guard.null(canvas);
|
||||
Guard.null(assetMetadata);
|
||||
|
||||
// If the canvas or asset are invalid return asset metadata
|
||||
if (!(canvas.width && canvas.height && assetMetadata.asset && assetMetadata.asset.size)) {
|
||||
return assetMetadata;
|
||||
}
|
||||
|
||||
await this.ensureModelLoaded();
|
||||
|
||||
const xRatio = assetMetadata.asset.size.width / canvas.width;
|
||||
const yRatio = assetMetadata.asset.size.height / canvas.height;
|
||||
const predictedRegions = await this.objectDetection.predictImage(
|
||||
canvas,
|
||||
this.settings.predictTag,
|
||||
xRatio,
|
||||
yRatio,
|
||||
);
|
||||
|
||||
const updatedRegions = [...assetMetadata.regions];
|
||||
predictedRegions.forEach((prediction) => {
|
||||
const matchingRegion = updatedRegions.find((region) => {
|
||||
return region.boundingBox
|
||||
&& region.boundingBox.left === prediction.boundingBox.left
|
||||
&& region.boundingBox.top === prediction.boundingBox.top
|
||||
&& region.boundingBox.width === prediction.boundingBox.width
|
||||
&& region.boundingBox.height === prediction.boundingBox.height;
|
||||
});
|
||||
|
||||
if (updatedRegions.length === 0 || !matchingRegion) {
|
||||
updatedRegions.push(prediction);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...assetMetadata,
|
||||
regions: updatedRegions,
|
||||
asset: {
|
||||
...assetMetadata.asset,
|
||||
state: updatedRegions.length > 0 ? AssetState.Tagged : AssetState.Visited,
|
||||
predicted: true,
|
||||
},
|
||||
} as IAssetMetadata;
|
||||
}
|
||||
|
||||
public async ensureModelLoaded(): Promise<void> {
|
||||
if (this.modelLoaded) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
await this.loadModel();
|
||||
this.modelLoaded = true;
|
||||
}
|
||||
|
||||
private async loadModel() {
|
||||
let modelPath = "";
|
||||
if (this.settings.modelPathType === ModelPathType.Coco) {
|
||||
if (isElectron()) {
|
||||
const appPath = this.getAppPath();
|
||||
|
||||
if (Env.get() !== "production") {
|
||||
modelPath = appPath + "/cocoSSDModel";
|
||||
} else {
|
||||
modelPath = appPath + "/../../cocoSSDModel";
|
||||
}
|
||||
} else {
|
||||
modelPath = "https://vott.blob.core.windows.net/coco-ssd-model";
|
||||
}
|
||||
} else if (this.settings.modelPathType === ModelPathType.File) {
|
||||
if (isElectron()) {
|
||||
modelPath = this.settings.modelPath;
|
||||
}
|
||||
} else {
|
||||
modelPath = this.settings.modelUrl;
|
||||
}
|
||||
|
||||
await this.objectDetection.load(modelPath);
|
||||
}
|
||||
|
||||
private getAppPath = () => {
|
||||
const remote = (window as any).require("electron").remote as Electron.Remote;
|
||||
return remote.app.getAppPath();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AssetService } from "./assetService";
|
||||
import { AssetType, IAssetMetadata, AssetState } from "../models/applicationState";
|
||||
import { AssetType, IAssetMetadata, AssetState, IAsset, IProject } from "../models/applicationState";
|
||||
import MockFactory from "../common/mockFactory";
|
||||
import { AssetProviderFactory, IAssetProvider } from "../providers/storage/assetProviderFactory";
|
||||
import { StorageProviderFactory } from "../providers/storage/storageProviderFactory";
|
||||
@@ -7,6 +7,8 @@ import { constants } from "../common/constants";
|
||||
import { TFRecordsBuilder, FeatureType } from "../providers/export/tensorFlowRecords/tensorFlowBuilder";
|
||||
import HtmlFileReader from "../common/htmlFileReader";
|
||||
import { encodeFileURI } from "../common/utils";
|
||||
import _ from "lodash";
|
||||
import registerMixins from "../registerMixins";
|
||||
|
||||
describe("Asset Service", () => {
|
||||
describe("Static Methods", () => {
|
||||
@@ -323,4 +325,105 @@ describe("Asset Service", () => {
|
||||
expect(Math.floor(result.regions[1].points[1].y)).toEqual(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tag Update functions", () => {
|
||||
|
||||
function populateProjectAssets(project?: IProject, assetCount = 10) {
|
||||
if (!project) {
|
||||
project = MockFactory.createTestProject();
|
||||
}
|
||||
const assets = MockFactory.createTestAssets(assetCount);
|
||||
assets.forEach((asset) => {
|
||||
asset.state = AssetState.Tagged;
|
||||
});
|
||||
|
||||
project.assets = _.keyBy(assets, (asset) => asset.id);
|
||||
return project;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
registerMixins();
|
||||
});
|
||||
|
||||
it("Deletes tag from assets", async () => {
|
||||
const tag1 = "tag1";
|
||||
const tag2 = "tag2";
|
||||
const region = MockFactory.createTestRegion(undefined, [tag1, tag2]);
|
||||
const asset: IAsset = {
|
||||
...MockFactory.createTestAsset("1"),
|
||||
state: AssetState.Tagged,
|
||||
};
|
||||
const assetMetadata = MockFactory.createTestAssetMetadata(asset, [region]);
|
||||
AssetService.prototype.getAssetMetadata = jest.fn((asset: IAsset) => Promise.resolve(assetMetadata));
|
||||
|
||||
const expectedAssetMetadata: IAssetMetadata = {
|
||||
...MockFactory.createTestAssetMetadata(
|
||||
asset,
|
||||
[
|
||||
{
|
||||
...region,
|
||||
tags: [tag2],
|
||||
},
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
const project = populateProjectAssets();
|
||||
const assetService = new AssetService(project);
|
||||
const assetUpdates = await assetService.deleteTag(tag1);
|
||||
|
||||
expect(assetUpdates).toHaveLength(1);
|
||||
expect(assetUpdates[0]).toEqual(expectedAssetMetadata);
|
||||
});
|
||||
|
||||
it("Deletes empty regions after deleting only tag from region", async () => {
|
||||
const tag1 = "tag1";
|
||||
const region = MockFactory.createTestRegion(undefined, [tag1]);
|
||||
const asset: IAsset = {
|
||||
...MockFactory.createTestAsset("1"),
|
||||
state: AssetState.Tagged,
|
||||
};
|
||||
const assetMetadata = MockFactory.createTestAssetMetadata(asset, [region]);
|
||||
AssetService.prototype.getAssetMetadata = jest.fn((asset: IAsset) => Promise.resolve(assetMetadata));
|
||||
|
||||
const expectedAssetMetadata: IAssetMetadata = MockFactory.createTestAssetMetadata(asset, []);
|
||||
const project = populateProjectAssets();
|
||||
const assetService = new AssetService(project);
|
||||
const assetUpdates = await assetService.deleteTag(tag1);
|
||||
|
||||
expect(assetUpdates).toHaveLength(1);
|
||||
expect(assetUpdates[0]).toEqual(expectedAssetMetadata);
|
||||
});
|
||||
|
||||
it("Updates renamed tag within all assets", async () => {
|
||||
const tag1 = "tag1";
|
||||
const newTag = "tag2";
|
||||
const region = MockFactory.createTestRegion(undefined, [tag1]);
|
||||
const asset: IAsset = {
|
||||
...MockFactory.createTestAsset("1"),
|
||||
state: AssetState.Tagged,
|
||||
};
|
||||
const assetMetadata = MockFactory.createTestAssetMetadata(asset, [region]);
|
||||
AssetService.prototype.getAssetMetadata = jest.fn((asset: IAsset) => Promise.resolve(assetMetadata));
|
||||
|
||||
const expectedAssetMetadata: IAssetMetadata = {
|
||||
...MockFactory.createTestAssetMetadata(
|
||||
asset,
|
||||
[
|
||||
{
|
||||
...region,
|
||||
tags: [newTag],
|
||||
},
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
const project = populateProjectAssets();
|
||||
const assetService = new AssetService(project);
|
||||
const assetUpdates = await assetService.renameTag(tag1, newTag);
|
||||
|
||||
expect(assetUpdates).toHaveLength(1);
|
||||
expect(assetUpdates[0]).toEqual(expectedAssetMetadata);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,10 +204,73 @@ export class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag from asset metadata files
|
||||
* @param tagName Name of tag to delete
|
||||
*/
|
||||
public async deleteTag(tagName: string): Promise<IAssetMetadata[]> {
|
||||
const transformer = (tags) => tags.filter((t) => t !== tagName);
|
||||
return await this.getUpdatedAssets(tagName, transformer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a tag within asset metadata files
|
||||
* @param tagName Name of tag to rename
|
||||
*/
|
||||
public async renameTag(tagName: string, newTagName: string): Promise<IAssetMetadata[]> {
|
||||
const transformer = (tags) => tags.map((t) => (t === tagName) ? newTagName : t);
|
||||
return await this.getUpdatedAssets(tagName, transformer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tags within asset metadata files
|
||||
* @param tagName Name of tag to update within project
|
||||
* @param transformer Function that accepts array of tags from a region and returns a modified array of tags
|
||||
*/
|
||||
private async getUpdatedAssets(tagName: string, transformer: (tags: string[]) => string[])
|
||||
: Promise<IAssetMetadata[]> {
|
||||
// Loop over assets and update if necessary
|
||||
const updates = await _.values(this.project.assets).mapAsync(async (asset) => {
|
||||
const assetMetadata = await this.getAssetMetadata(asset);
|
||||
const isUpdated = this.updateTagInAssetMetadata(assetMetadata, tagName, transformer);
|
||||
|
||||
return isUpdated ? assetMetadata : null;
|
||||
});
|
||||
|
||||
return updates.filter((assetMetadata) => !!assetMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tag within asset metadata object
|
||||
* @param assetMetadata Asset metadata to update
|
||||
* @param tagName Name of tag being updated
|
||||
* @param transformer Function that accepts array of tags from a region and returns a modified array of tags
|
||||
* @returns Modified asset metadata object or null if object does not need to be modified
|
||||
*/
|
||||
private updateTagInAssetMetadata(
|
||||
assetMetadata: IAssetMetadata,
|
||||
tagName: string,
|
||||
transformer: (tags: string[]) => string[]): boolean {
|
||||
let foundTag = false;
|
||||
|
||||
for (const region of assetMetadata.regions) {
|
||||
if (region.tags.find((t) => t === tagName)) {
|
||||
foundTag = true;
|
||||
region.tags = transformer(region.tags);
|
||||
}
|
||||
}
|
||||
if (foundTag) {
|
||||
assetMetadata.regions = assetMetadata.regions.filter((region) => region.tags.length > 0);
|
||||
assetMetadata.asset.state = (assetMetadata.regions.length) ? AssetState.Tagged : AssetState.Visited;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async getRegionsFromTFRecord(asset: IAsset): Promise<IRegion[]> {
|
||||
const objectArray = await this.getTFRecordMetadata(asset);
|
||||
const regions: IRegion[] = [];
|
||||
const tags: string[] = [];
|
||||
|
||||
// Add Regions from TFRecord in Regions
|
||||
for (let index = 0; index < objectArray.textArray.length; index++) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import shortid from "shortid";
|
||||
import {
|
||||
IProject, ITag, IConnection, AppError, ErrorCode,
|
||||
IAssetMetadata, IRegion, RegionType, AssetState, IFileInfo,
|
||||
IAsset, AssetType,
|
||||
IAsset, AssetType, ModelPathType,
|
||||
} from "../models/applicationState";
|
||||
import { IV1Project, IV1Region } from "../models/v1Models";
|
||||
import packageJson from "../../package.json";
|
||||
@@ -66,6 +66,7 @@ export default class ImportService implements IImportService {
|
||||
videoSettings: {
|
||||
frameExtractionRate: originalProject.framerate ? Number(originalProject.framerate) : 15,
|
||||
},
|
||||
activeLearningSettings: null,
|
||||
autoSave: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,11 +2,17 @@ import _ from "lodash";
|
||||
import ProjectService, { IProjectService } from "./projectService";
|
||||
import MockFactory from "../common/mockFactory";
|
||||
import { StorageProviderFactory } from "../providers/storage/storageProviderFactory";
|
||||
import { IProject, IExportFormat, ISecurityToken, AssetState } from "../models/applicationState";
|
||||
import {
|
||||
IProject, IExportFormat, ISecurityToken,
|
||||
AssetState, IActiveLearningSettings, ModelPathType,
|
||||
} from "../models/applicationState";
|
||||
import { constants } from "../common/constants";
|
||||
import { ExportProviderFactory } from "../providers/export/exportProviderFactory";
|
||||
import { generateKey } from "../common/crypto";
|
||||
import { encryptProject } from "../common/utils";
|
||||
import { encryptProject, decryptProject } from "../common/utils";
|
||||
import { ExportAssetState } from "../providers/export/exportProvider";
|
||||
import { IVottJsonExportProviderOptions } from "../providers/export/vottJson";
|
||||
import { IPascalVOCExportProviderOptions } from "../providers/export/pascalVOC";
|
||||
|
||||
describe("Project Service", () => {
|
||||
let projectSerivce: IProjectService = null;
|
||||
@@ -76,6 +82,45 @@ describe("Project Service", () => {
|
||||
expect.any(String));
|
||||
});
|
||||
|
||||
it("sets default export settings when not defined", async () => {
|
||||
testProject.exportFormat = null;
|
||||
const result = await projectSerivce.save(testProject, securityToken);
|
||||
|
||||
const vottJsonExportProviderOptions: IVottJsonExportProviderOptions = {
|
||||
assetState: ExportAssetState.Visited,
|
||||
includeImages: true,
|
||||
};
|
||||
|
||||
const expectedExportFormat: IExportFormat = {
|
||||
providerType: "vottJson",
|
||||
providerOptions: vottJsonExportProviderOptions,
|
||||
};
|
||||
|
||||
const decryptedProject = decryptProject(result, securityToken);
|
||||
|
||||
expect(decryptedProject.exportFormat).toEqual(expectedExportFormat);
|
||||
});
|
||||
|
||||
it("sets default active learning setting when not defined", async () => {
|
||||
testProject.activeLearningSettings = null;
|
||||
const result = await projectSerivce.save(testProject, securityToken);
|
||||
|
||||
const activeLearningSettings: IActiveLearningSettings = {
|
||||
autoDetect: false,
|
||||
predictTag: true,
|
||||
modelPathType: ModelPathType.Coco,
|
||||
};
|
||||
|
||||
expect(result.activeLearningSettings).toEqual(activeLearningSettings);
|
||||
});
|
||||
|
||||
it("initializes tags to empty array if not defined", async () => {
|
||||
testProject.tags = null;
|
||||
const result = await projectSerivce.save(testProject, securityToken);
|
||||
|
||||
expect(result.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it("Save calls configured export provider save when defined", async () => {
|
||||
testProject.exportFormat = {
|
||||
providerType: "azureCustomVision",
|
||||
@@ -157,4 +202,23 @@ describe("Project Service", () => {
|
||||
await projectSerivce.delete(testProject);
|
||||
expect(storageProviderMock.deleteFile.mock.calls).toHaveLength(assets.length + 1);
|
||||
});
|
||||
|
||||
it("Updates export settings to v2.1 supported values", async () => {
|
||||
testProject = MockFactory.createTestProject("TestProject");
|
||||
testProject.version = "2.0.0";
|
||||
testProject.exportFormat = {
|
||||
providerType: "tensorFlowPascalVOC",
|
||||
providerOptions: {
|
||||
assetState: ExportAssetState.All,
|
||||
exportUnassigned: true,
|
||||
testTrainSplit: 80,
|
||||
} as IPascalVOCExportProviderOptions,
|
||||
};
|
||||
|
||||
const encryptedProject = encryptProject(testProject, securityToken);
|
||||
const decryptedProject = await projectSerivce.load(encryptedProject, securityToken);
|
||||
|
||||
expect(decryptedProject.exportFormat.providerType).toEqual("pascalVOC");
|
||||
expect(decryptedProject.exportFormat.providerOptions).toEqual(testProject.exportFormat.providerOptions);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import _ from "lodash";
|
||||
import shortid from "shortid";
|
||||
import { StorageProviderFactory } from "../providers/storage/storageProviderFactory";
|
||||
import { IProject, ISecurityToken, AppError, ErrorCode, AssetState } from "../models/applicationState";
|
||||
import {
|
||||
IProject, ISecurityToken, AppError,
|
||||
ErrorCode, ModelPathType, IActiveLearningSettings,
|
||||
} from "../models/applicationState";
|
||||
import Guard from "../common/guard";
|
||||
import { constants } from "../common/constants";
|
||||
import { ExportProviderFactory } from "../providers/export/exportProviderFactory";
|
||||
import { decryptProject, encryptProject } from "../common/utils";
|
||||
import packageJson from "../../package.json";
|
||||
import { ExportAssetState } from "../providers/export/exportProvider";
|
||||
import { IExportFormat } from "vott-react";
|
||||
|
||||
/**
|
||||
* Functions required for a project service
|
||||
@@ -20,6 +25,20 @@ export interface IProjectService {
|
||||
isDuplicate(project: IProject, projectList: IProject[]): boolean;
|
||||
}
|
||||
|
||||
const defaultActiveLearningSettings: IActiveLearningSettings = {
|
||||
autoDetect: false,
|
||||
predictTag: true,
|
||||
modelPathType: ModelPathType.Coco,
|
||||
};
|
||||
|
||||
const defaultExportOptions: IExportFormat = {
|
||||
providerType: "vottJson",
|
||||
providerOptions: {
|
||||
assetState: ExportAssetState.Visited,
|
||||
includeImages: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @name - Project Service
|
||||
* @description - Functions for dealing with projects
|
||||
@@ -35,7 +54,25 @@ export default class ProjectService implements IProjectService {
|
||||
|
||||
try {
|
||||
const loadedProject = decryptProject(project, securityToken);
|
||||
return Promise.resolve(loadedProject);
|
||||
|
||||
// Ensure tags is always initialized to an array
|
||||
if (!loadedProject.tags) {
|
||||
loadedProject.tags = [];
|
||||
}
|
||||
|
||||
// Initialize active learning settings if they don't exist
|
||||
if (!loadedProject.activeLearningSettings) {
|
||||
loadedProject.activeLearningSettings = defaultActiveLearningSettings;
|
||||
}
|
||||
|
||||
// Initialize export settings if they don't exist
|
||||
if (!loadedProject.exportFormat) {
|
||||
loadedProject.exportFormat = defaultExportOptions;
|
||||
}
|
||||
|
||||
this.ensureBackwardsCompatibility(loadedProject);
|
||||
|
||||
return Promise.resolve({ ...loadedProject });
|
||||
} catch (e) {
|
||||
const error = new AppError(ErrorCode.ProjectInvalidSecurityToken, "Error decrypting project settings");
|
||||
return Promise.reject(error);
|
||||
@@ -54,6 +91,21 @@ export default class ProjectService implements IProjectService {
|
||||
project.id = shortid.generate();
|
||||
}
|
||||
|
||||
// Ensure tags is always initialized to an array
|
||||
if (!project.tags) {
|
||||
project.tags = [];
|
||||
}
|
||||
|
||||
// Initialize active learning settings if they don't exist
|
||||
if (!project.activeLearningSettings) {
|
||||
project.activeLearningSettings = defaultActiveLearningSettings;
|
||||
}
|
||||
|
||||
// Initialize export settings if they don't exist
|
||||
if (!project.exportFormat) {
|
||||
project.exportFormat = defaultExportOptions;
|
||||
}
|
||||
|
||||
project.version = packageJson.version;
|
||||
|
||||
const storageProvider = StorageProviderFactory.createFromConnection(project.targetConnection);
|
||||
@@ -113,4 +165,19 @@ export default class ProjectService implements IProjectService {
|
||||
|
||||
project.exportFormat.providerOptions = await exportProvider.save(project.exportFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures backwards compatibility with project
|
||||
* @param project The project to update
|
||||
*/
|
||||
private ensureBackwardsCompatibility(project: IProject) {
|
||||
const projectVersion = project.version.toLowerCase();
|
||||
|
||||
if (projectVersion.startsWith("2.0.0")) {
|
||||
// Required for backwards compatibility with v2.0.0 release
|
||||
if (project.exportFormat.providerType === "tensorFlowPascalVOC") {
|
||||
project.exportFormat.providerType = "pascalVOC";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-22
@@ -1,25 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"allowJs": false,
|
||||
"skipLibCheck": false,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"experimentalDecorators": true,
|
||||
"lib": [
|
||||
"es2015",
|
||||
"dom"
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"allowJs": false,
|
||||
"skipLibCheck": false,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"experimentalDecorators": true,
|
||||
"lib": [
|
||||
"es2015",
|
||||
"dom"
|
||||
],
|
||||
"typeRoots": [
|
||||
"./typings",
|
||||
"./node_modules/@types"
|
||||
],
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"globalDevDependencies": {
|
||||
"react-jsonschema-form": "registry:dt/react-jsonschema-form#0.43.0+20170226152137"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Generated by typings
|
||||
// Source: https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/46d11bc6ab4a0a9b711b403662dceefb67ebb575/react-jsonschema-form/index.d.ts
|
||||
declare module "react-jsonschema-form" {
|
||||
import * as React from "react";
|
||||
|
||||
export interface FormProps {
|
||||
schema: {};
|
||||
uiSchema?: {};
|
||||
formData?: any;
|
||||
widgets?: {};
|
||||
fields?: {};
|
||||
noValidate?: boolean;
|
||||
noHtml5Validate?: boolean;
|
||||
showErrorList?: boolean;
|
||||
validate?: (formData: any, errors: any) => any;
|
||||
onChange?: (e: IChangeEvent) => any;
|
||||
onError?: (e: any) => any;
|
||||
onSubmit?: (e: any) => any;
|
||||
liveValidate?: boolean;
|
||||
safeRenderCompletion?: boolean;
|
||||
}
|
||||
|
||||
export interface IChangeEvent {
|
||||
edit: boolean;
|
||||
formData: any;
|
||||
errors: any[];
|
||||
errorSchema: any;
|
||||
idSchema: any;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default class Form extends React.Component<FormProps, any> {}
|
||||
}
|
||||
Alguns arquivos não foram exibidos porque demasiados arquivos foram alterados neste diff Mostrar Mais
Referência em uma Nova Issue
Bloquear um usuário