From bd436f32fff7ddd8d54475f011b89d486bc099e9 Mon Sep 17 00:00:00 2001 From: Chonghan Date: Tue, 25 Jul 2023 00:08:50 -0400 Subject: [PATCH 01/58] Add db setup doc; remove useless db files --- database/README.md | 27 ++++ database/data.mwb | Bin 11579 -> 0 bytes database/data.mwb.bak | Bin 11223 -> 0 bytes database/init_schema.sql | 150 ------------------- database/init_schema.sqlite | 34 ----- database/mysql2sqlite | 289 ------------------------------------ 6 files changed, 27 insertions(+), 473 deletions(-) create mode 100644 database/README.md delete mode 100644 database/data.mwb delete mode 100644 database/data.mwb.bak delete mode 100644 database/init_schema.sql delete mode 100644 database/init_schema.sqlite delete mode 100644 database/mysql2sqlite diff --git a/database/README.md b/database/README.md new file mode 100644 index 00000000..d056bc5c --- /dev/null +++ b/database/README.md @@ -0,0 +1,27 @@ +# Database Dev Guide + +## Prep + +Download `SQLiteStudio` from https://sqlitestudio.pl/ (which is a handy GUI-client for SQLite) and install it. + +## Clean up + +1. Delete `/Robustar/data.db` +2. Open `SQLiteStudio`. Remove the `robustar-latest` database if it already exists. + +## Modifying Database Schema + +1. In `SQLiteStudio`, hover on `Database` dropdown and click `Add a database`, select `database/robustar-latest.db` under the project folder. Name the new database `robustar-latest`. +2. Modify the db schema with the GUI as you wish. +3. When done, hover on `Database` dropdown and click `Export the database`. +4. Make sure all tables are checked and **uncheck** `Export data from tables` as we don't need any data in the generated SQL. Click `Next`. +5. Export format is SQL, output as file to `database/robustar-latest.sql` under the project folder folder. +6. Use utf-8 in `Export text encoding`. **Do not** include `DROP IF EXISTS` and **Do not** use SQL formatter. +7. Click `Finish`. + +## Update Robustar To Use New Schema + +1. Copy the content of newly generated `robustar-latest.sql` file into the `get_init_schema_str` function in `utils/db.py`. +2. Run the backend. A new `/Robustar/data.db` will be generated. +3. Replacing `database/robustar-latest.db` with the newly generated `/Robustar/data.db`. +4. Push the changes. diff --git a/database/data.mwb b/database/data.mwb deleted file mode 100644 index 8970147c83e9f5968d5332e2bb9d73116e5d1c4d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11579 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 diff --git a/database/data.mwb.bak b/database/data.mwb.bak deleted file mode 100644 index df9868079dfbd7b561eb3bdd6820a5ecea7251e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11223 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 diff --git a/database/init_schema.sql b/database/init_schema.sql deleted file mode 100644 index f158fc7f..00000000 --- a/database/init_schema.sql +++ /dev/null @@ -1,150 +0,0 @@ --- MySQL Script generated by MySQL Workbench --- Sat Apr 23 16:14:55 2022 --- Model: New Model Version: 1.0 --- MySQL Workbench Forward Engineering - -SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; -SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; -SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; - --- ----------------------------------------------------- --- Schema robustar --- ----------------------------------------------------- - --- ----------------------------------------------------- --- Schema robustar --- ----------------------------------------------------- -CREATE SCHEMA IF NOT EXISTS `robustar` DEFAULT CHARACTER SET utf8 ; -USE `robustar` ; - --- ----------------------------------------------------- --- Table `robustar`.`split` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `robustar`.`split` ( - `id` INT NOT NULL AUTO_INCREMENT, - `split_name` VARCHAR(45) NULL, - PRIMARY KEY (`id`), - UNIQUE INDEX `id_UNIQUE` (`id` ASC) VISIBLE) -ENGINE = InnoDB; - - --- ----------------------------------------------------- --- Table `robustar`.`visu_rel` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `robustar`.`visu_rel` ( - `id` INT NOT NULL AUTO_INCREMENT, - `img_id` INT NULL, - `visu_type` VARCHAR(45) NULL COMMENT 'Can be ', - `visu_path` VARCHAR(45) NULL, - PRIMARY KEY (`id`), - UNIQUE INDEX `id_UNIQUE` (`id` ASC) VISIBLE) -ENGINE = InnoDB; - - --- ----------------------------------------------------- --- Table `robustar`.`paired_set` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `robustar`.`paired_set` ( - `id` INT NOT NULL AUTO_INCREMENT, - `img_path` VARCHAR(256) NULL, - `train_id` INT NULL, - `visu_rel_id` INT NULL, - PRIMARY KEY (`id`), - UNIQUE INDEX `id_UNIQUE` (`id` ASC) VISIBLE, - INDEX `paired_visu_rel_idx` (`visu_rel_id` ASC) VISIBLE, - CONSTRAINT `paired_visu_rel` - FOREIGN KEY (`visu_rel_id`) - REFERENCES `robustar`.`visu_rel` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB; - - --- ----------------------------------------------------- --- Table `robustar`.`train_set` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `robustar`.`train_set` ( - `id` INT NOT NULL AUTO_INCREMENT, - `img_path` VARCHAR(256) NULL, - `annotated` TINYINT NULL, - `paired_id` INT NULL, - `visu_rel_id` INT NULL, - PRIMARY KEY (`id`), - UNIQUE INDEX `id_UNIQUE` (`id` ASC) VISIBLE, - INDEX `train-paired_idx` (`paired_id` ASC) VISIBLE, - INDEX `train-visu_idx` (`visu_rel_id` ASC) VISIBLE, - CONSTRAINT `train-paired` - FOREIGN KEY (`paired_id`) - REFERENCES `robustar`.`paired_set` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION, - CONSTRAINT `train-visu` - FOREIGN KEY (`visu_rel_id`) - REFERENCES `robustar`.`visu_rel` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB; - - --- ----------------------------------------------------- --- Table `robustar`.`influ_rel` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `robustar`.`influ_rel` ( - `id` INT NOT NULL AUTO_INCREMENT, - `influ_path` VARCHAR(256) NULL, - PRIMARY KEY (`id`), - UNIQUE INDEX `id_UNIQUE` (`id` ASC) VISIBLE) -ENGINE = InnoDB; - - --- ----------------------------------------------------- --- Table `robustar`.`val_set` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `robustar`.`val_set` ( - `id` INT NOT NULL AUTO_INCREMENT, - `classified` TINYINT NULL, - `influ_rel_id` INT NULL, - PRIMARY KEY (`id`), - UNIQUE INDEX `id_UNIQUE` (`id` ASC) VISIBLE, - INDEX `val_influ_rel_idx` (`influ_rel_id` ASC) VISIBLE, - CONSTRAINT `val_influ_rel` - FOREIGN KEY (`influ_rel_id`) - REFERENCES `robustar`.`influ_rel` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB; - - --- ----------------------------------------------------- --- Table `robustar`.`test_set` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `robustar`.`test_set` ( - `id` INT NOT NULL AUTO_INCREMENT, - `classified` TINYINT NULL, - `influ_rel_id` INT NULL, - PRIMARY KEY (`id`), - UNIQUE INDEX `id_UNIQUE` (`id` ASC) VISIBLE, - INDEX `test_influ_rel_idx` (`influ_rel_id` ASC) VISIBLE, - CONSTRAINT `test_influ_rel` - FOREIGN KEY (`influ_rel_id`) - REFERENCES `robustar`.`influ_rel` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB; - - --- ----------------------------------------------------- --- Table `robustar`.`model` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `robustar`.`model` ( - `id` INT NOT NULL AUTO_INCREMENT, - `name` VARCHAR(256) NULL, - `type` VARCHAR(256) NULL, - PRIMARY KEY (`id`), - UNIQUE INDEX `id_UNIQUE` (`id` ASC) VISIBLE) -ENGINE = InnoDB; - - -SET SQL_MODE=@OLD_SQL_MODE; -SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; -SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; \ No newline at end of file diff --git a/database/init_schema.sqlite b/database/init_schema.sqlite deleted file mode 100644 index bbab08c9..00000000 --- a/database/init_schema.sqlite +++ /dev/null @@ -1,34 +0,0 @@ --- --- File generated with SQLiteStudio v3.3.3 on Sun Apr 24 01:33:14 2022 --- --- Text encoding used: System --- -PRAGMA foreign_keys = off; -BEGIN TRANSACTION; - --- Table: influ_rel -CREATE TABLE influ_rel (id INTEGER PRIMARY KEY UNIQUE NOT NULL, influ_path VARCHAR (256), eval_id INTEGER, train_id INTEGER); - --- Table: model -CREATE TABLE model (id INTEGER PRIMARY KEY UNIQUE NOT NULL, name VARCHAR (256), type VARCHAR (256)); - --- Table: paired_set -CREATE TABLE paired_set (id INTEGER PRIMARY KEY UNIQUE NOT NULL, img_path VARCHAR (256), train_id INTEGER, visu_rel_id INTEGER); - --- Table: split -CREATE TABLE split (id INTEGER PRIMARY KEY UNIQUE NOT NULL, split_name VARCHAR (45)); - --- Table: test_set -CREATE TABLE test_set (id INTEGER PRIMARY KEY UNIQUE NOT NULL, classified BOOLEAN); - --- Table: train_set -CREATE TABLE train_set (id INTEGER PRIMARY KEY UNIQUE NOT NULL, img_path VARCHAR (256), annotated BOOLEAN, paired_id INTEGER); - --- Table: val_set -CREATE TABLE val_set (id INTEGER PRIMARY KEY UNIQUE NOT NULL, classified BOOLEAN); - --- Table: visu_rel -CREATE TABLE visu_rel (id INTEGER PRIMARY KEY UNIQUE NOT NULL, img_id INTEGER, visu_type VARCHAR (45), visu_path VARCHAR (256)); - -COMMIT TRANSACTION; -PRAGMA foreign_keys = on; \ No newline at end of file diff --git a/database/mysql2sqlite b/database/mysql2sqlite deleted file mode 100644 index 8826e751..00000000 --- a/database/mysql2sqlite +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/awk -f -# Copied unmodified from https://github.com/dumblob/mysql2sqlite/blob/master/mysql2sqlite -# Authors: @esperlu, @artemyk, @gkuenning, @dumblob - -# FIXME detect empty input file and issue a warning - -function printerr( s ){ print s | "cat >&2" } - -BEGIN { - if( ARGC != 2 ){ - printerr( \ - "USAGE:\n"\ - " mysql2sqlite dump_mysql.sql > dump_sqlite3.sql\n" \ - " OR\n" \ - " mysql2sqlite dump_mysql.sql | sqlite3 sqlite.db\n" \ - "\n" \ - "NOTES:\n" \ - " Dash in filename is not supported, because dash (-) means stdin." ) - no_END = 1 - exit 1 - } - - # Find INT_MAX supported by both this AWK (usually an ISO C signed int) - # and SQlite. - # On non-8bit-based architectures, the additional bits are safely ignored. - - # 8bit (lower precision should not exist) - s="127" - # "63" + 0 avoids potential parser misbehavior - if( (s + 0) "" == s ){ INT_MAX_HALF = "63" + 0 } - # 16bit - s="32767" - if( (s + 0) "" == s ){ INT_MAX_HALF = "16383" + 0 } - # 32bit - s="2147483647" - if( (s + 0) "" == s ){ INT_MAX_HALF = "1073741823" + 0 } - # 64bit (as INTEGER in SQlite3) - s="9223372036854775807" - if( (s + 0) "" == s ){ INT_MAX_HALF = "4611686018427387904" + 0 } -# # 128bit -# s="170141183460469231731687303715884105728" -# if( (s + 0) "" == s ){ INT_MAX_HALF = "85070591730234615865843651857942052864" + 0 } -# # 256bit -# s="57896044618658097711785492504343953926634992332820282019728792003956564819968" -# if( (s + 0) "" == s ){ INT_MAX_HALF = "28948022309329048855892746252171976963317496166410141009864396001978282409984" + 0 } -# # 512bit -# s="6703903964971298549787012499102923063739682910296196688861780721860882015036773488400937149083451713845015929093243025426876941405973284973216824503042048" -# if( (s + 0) "" == s ){ INT_MAX_HALF = "3351951982485649274893506249551461531869841455148098344430890360930441007518386744200468574541725856922507964546621512713438470702986642486608412251521024" + 0 } -# # 1024bit -# s="89884656743115795386465259539451236680898848947115328636715040578866337902750481566354238661203768010560056939935696678829394884407208311246423715319737062188883946712432742638151109800623047059726541476042502884419075341171231440736956555270413618581675255342293149119973622969239858152417678164812112068608" -# if( (s + 0) "" == s ){ INT_MAX_HALF = "44942328371557897693232629769725618340449424473557664318357520289433168951375240783177119330601884005280028469967848339414697442203604155623211857659868531094441973356216371319075554900311523529863270738021251442209537670585615720368478277635206809290837627671146574559986811484619929076208839082406056034304" + 0 } -# # higher precision probably not needed - - FS=",$" - print "PRAGMA synchronous = OFF;" - print "PRAGMA journal_mode = MEMORY;" - print "BEGIN TRANSACTION;" -} - -# historically 3 spaces separate non-argument local variables -function bit_to_int( str_bit, powtwo, i, res, bit, overflow ){ - powtwo = 1 - overflow = 0 - # 011101 = 1*2^0 + 0*2^1 + 1*2^2 ... - for( i = length( str_bit ); i > 0; --i ){ - bit = substr( str_bit, i, 1 ) - if( overflow || ( bit == 1 && res > INT_MAX_HALF ) ){ - printerr( \ - NR ": WARN Bit field overflow, number truncated (LSBs saved, MSBs ignored)." ) - break - } - res = res + bit * powtwo - # no warning here as it might be the last iteration - if( powtwo > INT_MAX_HALF ){ overflow = 1; continue } - powtwo = powtwo * 2 - } - return res -} - -# CREATE TRIGGER statements have funny commenting. Remember we are in trigger. -/^\/\*.*(CREATE.*TRIGGER|create.*trigger)/ { - gsub( /^.*(TRIGGER|trigger)/, "CREATE TRIGGER" ) - print - inTrigger = 1 - next -} -# The end of CREATE TRIGGER has a stray comment terminator -/(END|end) \*\/;;/ { gsub( /\*\//, "" ); print; inTrigger = 0; next } -# The rest of triggers just get passed through -inTrigger != 0 { print; next } - -# CREATE VIEW looks like a TABLE in comments -/^\/\*.*(CREATE.*TABLE|create.*table)/ { - inView = 1 - next -} -# end of CREATE VIEW -/^(\).*(ENGINE|engine).*\*\/;)/ { - inView = 0 - next -} -# content of CREATE VIEW -inView != 0 { next } - -# skip comments -/^\/\*/ { next } - -# skip PARTITION statements -/^ *[(]?(PARTITION|partition) +[^ ]+/ { next } - -# print all INSERT lines -( /^ *\(/ && /\) *[,;] *$/ ) || /^(INSERT|insert|REPLACE|replace)/ { - prev = "" - - # first replace \\ by \_ that mysqldump never generates to deal with - # sequnces like \\n that should be translated into \n, not \. - # After we convert all escapes we replace \_ by backslashes. - gsub( /\\\\/, "\\_" ) - - # single quotes are escaped by another single quote - gsub( /\\'/, "''" ) - gsub( /\\n/, "\n" ) - gsub( /\\r/, "\r" ) - gsub( /\\"/, "\"" ) - gsub( /\\\032/, "\032" ) # substitute char - - gsub( /\\_/, "\\" ) - - # sqlite3 is limited to 16 significant digits of precision - while( match( $0, /0x[0-9a-fA-F]{17}/ ) ){ - hexIssue = 1 - sub( /0x[0-9a-fA-F]+/, substr( $0, RSTART, RLENGTH-1 ), $0 ) - } - if( hexIssue ){ - printerr( \ - NR ": WARN Hex number trimmed (length longer than 16 chars)." ) - hexIssue = 0 - } - print - next -} - -# CREATE DATABASE is not supported -/^(CREATE DATABASE|create database)/ { next } - -# print the CREATE line as is and capture the table name -/^(CREATE|create)/ { - if( $0 ~ /IF NOT EXISTS|if not exists/ || $0 ~ /TEMPORARY|temporary/ ){ - caseIssue = 1 - printerr( \ - NR ": WARN Potential case sensitivity issues with table/column naming\n" \ - " (see INFO at the end)." ) - } - if( match( $0, /`[^`]+/ ) ){ - tableName = substr( $0, RSTART+1, RLENGTH-1 ) - } - aInc = 0 - prev = "" - firstInTable = 1 - print - next -} - -# Replace `FULLTEXT KEY` (probably other `XXXXX KEY`) -/^ (FULLTEXT KEY|fulltext key)/ { gsub( /[A-Za-z ]+(KEY|key)/, " KEY" ) } - -# Get rid of field lengths in KEY lines -/ (PRIMARY |primary )?(KEY|key)/ { gsub( /\([0-9]+\)/, "" ) } - -aInc == 1 && /PRIMARY KEY|primary key/ { next } - -# Replace COLLATE xxx_xxxx_xx statements with COLLATE BINARY -/ (COLLATE|collate) [a-z0-9_]*/ { gsub( /(COLLATE|collate) [a-z0-9_]*/, "COLLATE BINARY" ) } - -# Print all fields definition lines except the `KEY` lines. -/^ / && !/^( (KEY|key)|\);)/ { - if( match( $0, /[^"`]AUTO_INCREMENT|auto_increment[^"`]/) ){ - aInc = 1 - gsub( /AUTO_INCREMENT|auto_increment/, "PRIMARY KEY AUTOINCREMENT" ) - } - gsub( /(UNIQUE KEY|unique key) (`.*`|".*") /, "UNIQUE " ) - gsub( /(CHARACTER SET|character set) [^ ]+[ ,]/, "" ) - # FIXME - # CREATE TRIGGER [UpdateLastTime] - # AFTER UPDATE - # ON Package - # FOR EACH ROW - # BEGIN - # UPDATE Package SET LastUpdate = CURRENT_TIMESTAMP WHERE ActionId = old.ActionId; - # END - gsub( /(ON|on) (UPDATE|update) (CURRENT_TIMESTAMP|current_timestamp)(\(\))?/, "" ) - gsub( /(DEFAULT|default) (CURRENT_TIMESTAMP|current_timestamp)(\(\))?/, "DEFAULT current_timestamp") - gsub( /(COLLATE|collate) [^ ]+ /, "" ) - gsub( /(ENUM|enum)[^)]+\)/, "text " ) - gsub( /(SET|set)\([^)]+\)/, "text " ) - gsub( /UNSIGNED|unsigned/, "" ) - gsub( /_utf8mb3/, "" ) - gsub( /` [^ ]*(INT|int|BIT|bit)[^ ]*/, "` integer" ) - gsub( /" [^ ]*(INT|int|BIT|bit)[^ ]*/, "\" integer" ) - ere_bit_field = "[bB]'[10]+'" - if( match($0, ere_bit_field) ){ - sub( ere_bit_field, bit_to_int( substr( $0, RSTART +2, RLENGTH -2 -1 ) ) ) - } - - # remove USING BTREE and other suffixes for USING, for example: "UNIQUE KEY - # `hostname_domain` (`hostname`,`domain`) USING BTREE," - gsub( / USING [^, ]+/, "" ) - - # field comments are not supported - gsub( / (COMMENT|comment).+$/, "" ) - # Get commas off end of line - gsub( /,.?$/, "" ) - if( prev ){ - if( firstInTable ){ - print prev - firstInTable = 0 - } - else { - print "," prev - } - } - else { - # FIXME check if this is correct in all cases - if( match( $1, - /(CONSTRAINT|constraint) ["].*["] (FOREIGN KEY|foreign key)/ ) ){ - print "," - } - } - prev = $1 -} - -/ ENGINE| engine/ { - if( prev ){ - if( firstInTable ){ - print prev - firstInTable = 0 - } - else { - print "," prev - } - } - prev="" - print ");" - next -} -# `KEY` lines are extracted from the `CREATE` block and stored in array for later print -# in a separate `CREATE KEY` command. The index name is prefixed by the table name to -# avoid a sqlite error for duplicate index name. -/^( (KEY|key)|\);)/ { - if( prev ){ - if( firstInTable ){ - print prev - firstInTable = 0 - } - else { - print "," prev - } - } - prev = "" - if( $0 == ");" ){ - print - } - else { - if( match( $0, /`[^`]+/ ) ){ - indexName = substr( $0, RSTART+1, RLENGTH-1 ) - } - if( match( $0, /\([^()]+/ ) ){ - indexKey = substr( $0, RSTART+1, RLENGTH-1 ) - } - # idx_ prefix to avoid name clashes (they really happen!) - key[tableName] = key[tableName] "CREATE INDEX \"idx_" \ - tableName "_" indexName "\" ON \"" tableName "\" (" indexKey ");\n" - } -} - -END { - if( no_END ){ exit 1} - # print all KEY creation lines. - for( table in key ){ printf key[table] } - - print "END TRANSACTION;" - - if( caseIssue ){ - printerr( \ - "INFO Pure sqlite identifiers are case insensitive (even if quoted\n" \ - " or if ASCII) and doesnt cross-check TABLE and TEMPORARY TABLE\n" \ - " identifiers. Thus expect errors like \"table T has no column named F\".") - } -} \ No newline at end of file From c2e50be85794f04ec7ba89a420dbb5c329f911b6 Mon Sep 17 00:00:00 2001 From: Chonghan Date: Wed, 26 Jul 2023 22:51:08 -0400 Subject: [PATCH 02/58] add database schema change note --- database/README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/database/README.md b/database/README.md index d056bc5c..3ba93524 100644 --- a/database/README.md +++ b/database/README.md @@ -25,3 +25,46 @@ Download `SQLiteStudio` from https://sqlitestudio.pl/ (which is a handy GUI-clie 2. Run the backend. A new `/Robustar/data.db` will be generated. 3. Replacing `database/robustar-latest.db` with the newly generated `/Robustar/data.db`. 4. Push the changes. + +## V0.3 schema change notes + +- **(new)** visuals + - image_path (index 1, part-of-pk) + - model_id (index 1, part-of-pk) + - visual_path (index 1, part-of-pk) +- visual_images + - path (pk) +- influ_rel + - **(new)** model_id (index 1, part-of-pk) + - image_path (index 1, part-of-pk) + - influ_path +- paired_set (no change) +- proposed (no change) +- split (no change) +- train_set (no change) +- val_set (no change) + - path (pk) + - ~~classified~~ +- test_set + - path (pk) + - ~~classified~~ +- **(new)** test_result + - model_id (index 1, part-of-pk) + - path (index 1, part-of-pk) + - result +- **(new)** val_result + - model_id (index 1, part-of-pk) + - path (index 1, part-of-pk) + - result +- **(new)** model + - model_id (pk, index 1) + - model_name (index 2) + - description + - architecture + - tags + - created_time + - weight_path + - code_path + - accuracies + - epoch + - last_tested From 9b5e2eae4cd27bc022994f86419771910303c6ad Mon Sep 17 00:00:00 2001 From: Chonghan Date: Wed, 26 Jul 2023 23:26:08 -0400 Subject: [PATCH 03/58] add db schema design diagram and code --- database/README.md | 112 +++++++++++++++++++++++++++++++++---- database/robustar-v0.3.png | Bin 0 -> 97535 bytes 2 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 database/robustar-v0.3.png diff --git a/database/README.md b/database/README.md index 3ba93524..4a44fa25 100644 --- a/database/README.md +++ b/database/README.md @@ -26,21 +26,21 @@ Download `SQLiteStudio` from https://sqlitestudio.pl/ (which is a handy GUI-clie 3. Replacing `database/robustar-latest.db` with the newly generated `/Robustar/data.db`. 4. Push the changes. -## V0.3 schema change notes +# V0.3 schema change notes + +## Design - **(new)** visuals - image_path (index 1, part-of-pk) - model_id (index 1, part-of-pk) - visual_path (index 1, part-of-pk) -- visual_images - - path (pk) - influ_rel - **(new)** model_id (index 1, part-of-pk) - image_path (index 1, part-of-pk) - influ_path - paired_set (no change) - proposed (no change) -- split (no change) +- ~~split~~ - train_set (no change) - val_set (no change) - path (pk) @@ -48,15 +48,15 @@ Download `SQLiteStudio` from https://sqlitestudio.pl/ (which is a handy GUI-clie - test_set - path (pk) - ~~classified~~ -- **(new)** test_result +- **(new)** test_results - model_id (index 1, part-of-pk) - - path (index 1, part-of-pk) + - test_path (index 1, part-of-pk) - result -- **(new)** val_result +- **(new)** val_results - model_id (index 1, part-of-pk) - - path (index 1, part-of-pk) + - val_path (index 1, part-of-pk) - result -- **(new)** model +- **(new)** models - model_id (pk, index 1) - model_name (index 2) - description @@ -65,6 +65,96 @@ Download `SQLiteStudio` from https://sqlitestudio.pl/ (which is a handy GUI-clie - created_time - weight_path - code_path - - accuracies + - train_accuracy + - dev_accuracy + - last_eval_on_dev_set + - test_accuracy + - last_eval_on_test_set - epoch - - last_tested + +## Diagram from `dbdiagram.io` + +![](robustar-v0.3.png) + +DBML Code: + +``` +Table visuals { + image_path string [primary key] + model_id integer [primary key] + visual_path string [primary key] +} + +Table influ_rel { + model_id integer [primary key] + image_path string [primary key] + influ_path string +} + +Table paired_set { + path string [primary key] + train_path string +} + +Table proposed { + path string [primary key] + train_path string +} + +Table train_set { + path string [primary key] + paired_path string +} + +Table val_set { + path string [primary key] +} + +Table test_set { + path string [primary key] +} + +Table test_results { + model_id integer [primary key] + test_path string [primary key] + result integer +} + +Table val_results { + model_id integer [primary key] + val_path string [primary key] + result integer +} + +Table models { + model_id string [primary key] + model_name string + description string + architecture string + tags string + created_time timestamp + weight_path string + code_path string + epoch integer + train_accuracy float + dev_accuracy float + last_eval_on_dev_set timestamp + test_accuracy float + last_eval_on_test_set timestamp +} + +// path relations +Ref: paired_set.train_path > train_set.path +Ref: proposed.train_path > train_set.path +Ref: test_results.test_path > test_set.path +Ref: val_results.val_path > val_set.path +Ref: visuals.image_path > train_set.path +Ref: visuals.image_path > val_set.path +Ref: visuals.image_path > test_set.path +Ref: influ_rel.image_path > test_set.path + +// model relations +Ref: val_results.model_id > models.model_id +Ref: test_results.model_id > models.model_id + +``` diff --git a/database/robustar-v0.3.png b/database/robustar-v0.3.png new file mode 100644 index 0000000000000000000000000000000000000000..eb5eebb2f07f2d27631097f1deac19963e52cb26 GIT binary patch literal 97535 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

wUX_DU;1iiXfu5Ia>-$)k1*Y9 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%0AG2aGcdb9jVreIay(5_CR6iJmpZt70OQ$v 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 literal 0 HcmV?d00001 From 63c2bb19bf6f216bddb3d575d0d9818e401c564f Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 30 Jul 2023 12:22:43 +0800 Subject: [PATCH 04/58] Create 'model.py' for initial model APIs sketch --- back-end/apis/model.py | 66 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 back-end/apis/model.py diff --git a/back-end/apis/model.py b/back-end/apis/model.py new file mode 100644 index 00000000..663a07f9 --- /dev/null +++ b/back-end/apis/model.py @@ -0,0 +1,66 @@ +import string +from flask import request +from flask import Blueprint + +model_api = Blueprint("model_api", __name__) + +@model_api.route("/model/current", methods=["GET"]) +def GetCurrModel(): + """ Get the model that is currently active """ + """ return data + { + id: string, + name: string, + details: string, + } + """ + pass + + +@model_api.route("/model/current/", methods=["POST"]) +def SetCurrModel(model_id: string): + """ return 200 on success """ + pass + + +@model_api.route("/model/", methods=["DELETE"]) +def DeleteModel(): + """ return data + { + id: string, + name: string, + details: string, + } + """ + pass + + +@model_api.route("/model", methods=["POST"]) +def UploadModel(): + """ Should also accept (optionally) a model weight file as argument """ + """ After training a model, should do the same""" + """ return data + { + id: string, + name: string, + details: string, + } + + """ + pass + + +@model_api.route("/model/list", methods=["GET"]) +def GetAllModels(): + """ return data + [ + { + id: string, + name: string, + details: string, + }, + ... + ] + """ + pass + From c38057374ed60cc871c219ccf447a363a352632d Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 30 Jul 2023 18:11:07 +0800 Subject: [PATCH 05/58] Add docstrings for the UploadModel API --- back-end/apis/model.py | 62 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 663a07f9..da2183c2 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -37,16 +37,62 @@ def DeleteModel(): @model_api.route("/model", methods=["POST"]) def UploadModel(): - """ Should also accept (optionally) a model weight file as argument """ - """ After training a model, should do the same""" - """ return data - { - id: string, - name: string, - details: string, - } + """ + Should also accept (optionally) a model weight file as argument + After training a model, should do the same + --- + tags: + - model + consumes: + - "multipart/form-data" + produces: + - "application/json" + parameters: + - in: "formData" # Use "formData" to indicate multipart/form-data parameters + name: "model_def" # Name of the parameter for the model definition code + description: "The definition of the model in Python code" + required: true + type: "string" # The type of data for the model definition code + - in: "formData" # Use "formData" to indicate multipart/form-data parameters + name: "weight_file" # Name of the parameter for the model weight file + description: "The weight file for the trained model (optional)" + required: false # The weight file is optional, so set 'required' to false + type: "file" # The type of data for the weight file (file upload) + + responses: + 200: + description: Model uploaded successfully + schema: + type: "object" + properties: + code: + type: "integer" + example: 0 + data: + type: "object" + properties: + id: + type: "string" + example: "abc123" + name: + type: "string" + example: "NewModel" + details: + type: "string" + example: "Uploaded successfully" + msg: + type: "string" + example: "Success" """ + print("Requested to upload a model") + + # Get the model definition code + model_def = request.form.get('model_def') + + # Get the weight file + weight_file = request.files.get('weight_file') + pass From 1b4edfe0e367ef0b559e276a6e6c878a9121bdb5 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 30 Jul 2023 22:25:57 +0800 Subject: [PATCH 06/58] Add 'model_utils.py' for utilities & outline 'UploadModel' API --- back-end/apis/model.py | 80 ++++++++++++++++++++++++++++++++--- back-end/utils/model_utils.py | 20 +++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 back-end/utils/model_utils.py diff --git a/back-end/apis/model.py b/back-end/apis/model.py index da2183c2..120de1ee 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -1,6 +1,11 @@ import string +import os +import torch from flask import request from flask import Blueprint +from utils.model_utils import init_model_from_def, val_model, save_model +from objects.RResponse import RResponse +from objects.RServer import RServer model_api = Blueprint("model_api", __name__) @@ -87,13 +92,78 @@ def UploadModel(): """ print("Requested to upload a model") - # Get the model definition code + # Get the model definition code and save it to a temporary file model_def = request.form.get('model_def') - - # Get the weight file + # TODO: discuss with team the path to save the temp file + def_file_path = os.path.join(RServer.get_server().base_dir, 'temp_def.py') + try: + with open(def_file_path, 'w') as temp_file: + temp_file.write(model_def) + except Exception as e: + # Delete the temporary definition file and return + os.remove(def_file_path) + return RResponse.abort(500, f"Failed to save the model definition. {e}") + + # Initialize the model + try: + model = init_model_from_def(def_file_path) + except Exception as e: + # Delete the temporary definition file and return + os.remove(def_file_path) + return RResponse.abort(400, f"Failed to initialize the model. {e}") + + # Get the weight file and save it to a temporary location if it exists weight_file = request.files.get('weight_file') - - pass + if weight_file is not None: + try: + weight_file_path = os.path.join(RServer.get_server().base_dir, 'temp_weights.pth') + weight_file.save(weight_file_path) + except Exception as e: + # Delete the weight file + os.remove(weight_file_path) + return RResponse.abort(500, f"Failed to save the weight file. {e}") + + # Load the weights from the file + try: + model.load_state_dict(torch.load(weight_file_path)) + except Exception as e: + # Delete the temp files + os.remove(def_file_path) + os.remove(weight_file_path) + return RResponse.abort(400, f"Failed to load the weight. {e}") + + # Validate the model + try: + val_model(model) + except Exception as e: + # Delete the temp files + os.remove(def_file_path) + os.remove(weight_file_path) + return RResponse.abort(400, f"The model is invalid. {e}") + + # TODO: generate a real model id + model_id = "abc123" + + # Save the model to the database + try: + save_model(model) + except Exception as e: + # Delete the temp files + os.remove(def_file_path) + os.remove(weight_file_path) + return RResponse.abort(500, f"Failed to save the model. {e}") + + # Set the current model to the newly uploaded model + try: + SetCurrModel(model_id) + except Exception as e: + # Delete the temp files + os.remove(def_file_path) + os.remove(weight_file_path) + return RResponse.abort(500, f"Failed to set the current model. {e}") + + # TODO: return real data as specified in the docstring + return RResponse.ok('Success') @model_api.route("/model/list", methods=["GET"]) diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py new file mode 100644 index 00000000..aff63c07 --- /dev/null +++ b/back-end/utils/model_utils.py @@ -0,0 +1,20 @@ +def init_model_from_def(def_file_path): + """ Initialize the model from the definition file + """ + try: + pass + except Exception as e: + print("Failed to initialize the model.") + print(e) + raise e + + +def val_model(model): + """ Validate the model by running the model against a small portion of the validation dataset + """ + pass + + +# TODO: align the arguments with the actual database design +def save_model(model): + pass From 00018f2acd6870f164b356d67963e7aeaae5dbbc Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Mon, 7 Aug 2023 08:38:32 +0800 Subject: [PATCH 07/58] Get all metadata of the model according to the db design --- back-end/apis/model.py | 49 +++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 120de1ee..fc845d37 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -92,53 +92,54 @@ def UploadModel(): """ print("Requested to upload a model") + # Get the model's metadata + name = request.form.get('model_name') + desc = request.form.get('desc') + arch = request.form.get('arch') + tags = request.form.get('tags') + created_time = request.form.get('created_time') + epoch = request.form.get('epoch') + train_acc = request.form.get('train_acc') + dev_acc = request.form.get('dev_acc') + last_eval_on_dev_set = request.form.get('last_eval_on_dev_set') + test_acc = request.form.get('test_acc') + last_eval_on_test_set = request.form.get('last_eval_on_test_set') + # Get the model definition code and save it to a temporary file - model_def = request.form.get('model_def') + code = request.form.get('code') # TODO: discuss with team the path to save the temp file - def_file_path = os.path.join(RServer.get_server().base_dir, 'temp_def.py') + code_path = os.path.join(RServer.get_server().base_dir, 'temp_code.py') try: - with open(def_file_path, 'w') as temp_file: - temp_file.write(model_def) + with open(code_path, 'w') as code_file: + code_file.write(code) except Exception as e: - # Delete the temporary definition file and return - os.remove(def_file_path) return RResponse.abort(500, f"Failed to save the model definition. {e}") # Initialize the model try: - model = init_model_from_def(def_file_path) + model = init_model_from_def(code_path) except Exception as e: - # Delete the temporary definition file and return - os.remove(def_file_path) return RResponse.abort(400, f"Failed to initialize the model. {e}") # Get the weight file and save it to a temporary location if it exists weight_file = request.files.get('weight_file') if weight_file is not None: try: - weight_file_path = os.path.join(RServer.get_server().base_dir, 'temp_weights.pth') - weight_file.save(weight_file_path) + weights_path = os.path.join(RServer.get_server().base_dir, 'temp_weights.pth') + weight_file.save(weights_path) except Exception as e: - # Delete the weight file - os.remove(weight_file_path) return RResponse.abort(500, f"Failed to save the weight file. {e}") # Load the weights from the file try: - model.load_state_dict(torch.load(weight_file_path)) + model.load_state_dict(torch.load(weights_path)) except Exception as e: - # Delete the temp files - os.remove(def_file_path) - os.remove(weight_file_path) - return RResponse.abort(400, f"Failed to load the weight. {e}") + return RResponse.abort(400, f"Failed to load the weights. {e}") # Validate the model try: val_model(model) except Exception as e: - # Delete the temp files - os.remove(def_file_path) - os.remove(weight_file_path) return RResponse.abort(400, f"The model is invalid. {e}") # TODO: generate a real model id @@ -148,18 +149,12 @@ def UploadModel(): try: save_model(model) except Exception as e: - # Delete the temp files - os.remove(def_file_path) - os.remove(weight_file_path) return RResponse.abort(500, f"Failed to save the model. {e}") # Set the current model to the newly uploaded model try: SetCurrModel(model_id) except Exception as e: - # Delete the temp files - os.remove(def_file_path) - os.remove(weight_file_path) return RResponse.abort(500, f"Failed to set the current model. {e}") # TODO: return real data as specified in the docstring From e7d012ea552e0692efe8f4d8426d18e499beea17 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Mon, 7 Aug 2023 09:07:47 +0800 Subject: [PATCH 08/58] Complete `init_model` function implementation --- back-end/apis/model.py | 4 ++-- back-end/utils/model_utils.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index fc845d37..b5e9d061 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -3,7 +3,7 @@ import torch from flask import request from flask import Blueprint -from utils.model_utils import init_model_from_def, val_model, save_model +from utils.model_utils import init_model, val_model, save_model from objects.RResponse import RResponse from objects.RServer import RServer @@ -117,7 +117,7 @@ def UploadModel(): # Initialize the model try: - model = init_model_from_def(code_path) + model = init_model(code_path, arch) except Exception as e: return RResponse.abort(400, f"Failed to initialize the model. {e}") diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index aff63c07..79d294c8 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -1,8 +1,15 @@ -def init_model_from_def(def_file_path): - """ Initialize the model from the definition file +import importlib + + +def init_model(code_path, arch): + """ Initialize the model by importing the class named arch in the file specified by code_path """ try: - pass + spec = importlib.util.spec_from_file_location("model_def", code_path) + model_def = importlib.util.module_from_spec(spec) + spec.loader.exec_module(model_def) + model = getattr(model_def, arch)() + return model except Exception as e: print("Failed to initialize the model.") print(e) From 87212fca4818c78c13088ca84c726e7fa02ebac2 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Tue, 8 Aug 2023 12:22:09 +0800 Subject: [PATCH 09/58] Extend `UploadModel` to handle uploading models after training --- back-end/apis/model.py | 113 ++++++++++++++++++---------------- back-end/utils/model_utils.py | 21 ++++++- 2 files changed, 80 insertions(+), 54 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index b5e9d061..76d82a6d 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -1,9 +1,10 @@ +import json import string import os import torch from flask import request from flask import Blueprint -from utils.model_utils import init_model, val_model, save_model +from utils.model_utils import * from objects.RResponse import RResponse from objects.RServer import RServer @@ -54,9 +55,14 @@ def UploadModel(): - "application/json" parameters: - in: "formData" # Use "formData" to indicate multipart/form-data parameters - name: "model_def" # Name of the parameter for the model definition code + name: "metadata" # Name of the parameter for the model metadata + description: "The metadata of the model" + required: true # The metadata is required, so set 'required' to true + type: "string" # The type of data for the metadata + - in: "formData" # Use "formData" to indicate multipart/form-data parameters + name: "code" # Name of the parameter for the model definition code description: "The definition of the model in Python code" - required: true + required: false # The model definition code is optional, so set 'required' to false type: "string" # The type of data for the model definition code - in: "formData" # Use "formData" to indicate multipart/form-data parameters name: "weight_file" # Name of the parameter for the model weight file @@ -90,64 +96,67 @@ def UploadModel(): example: "Success" """ - print("Requested to upload a model") - # Get the model's metadata - name = request.form.get('model_name') - desc = request.form.get('desc') - arch = request.form.get('arch') - tags = request.form.get('tags') - created_time = request.form.get('created_time') - epoch = request.form.get('epoch') - train_acc = request.form.get('train_acc') - dev_acc = request.form.get('dev_acc') - last_eval_on_dev_set = request.form.get('last_eval_on_dev_set') - test_acc = request.form.get('test_acc') - last_eval_on_test_set = request.form.get('last_eval_on_test_set') - - # Get the model definition code and save it to a temporary file - code = request.form.get('code') - # TODO: discuss with team the path to save the temp file - code_path = os.path.join(RServer.get_server().base_dir, 'temp_code.py') - try: - with open(code_path, 'w') as code_file: - code_file.write(code) - except Exception as e: - return RResponse.abort(500, f"Failed to save the model definition. {e}") + metadata = json.loads(request.form.get('metadata')) - # Initialize the model - try: - model = init_model(code_path, arch) - except Exception as e: - return RResponse.abort(400, f"Failed to initialize the model. {e}") + # If it is a new model, validate it and update code_path and weight_path in its metadata + if 'code' in request.form: + print("Requested to upload a new model") - # Get the weight file and save it to a temporary location if it exists - weight_file = request.files.get('weight_file') - if weight_file is not None: + # Get the model id + model_id = metadata.get('model_id') + + # Get the model definition code and save it to a temporary file + code = request.form.get('code') + # TODO: discuss with team the path to save the definition and weight file + code_path = os.path.join(RServer.get_server().base_dir, f'{model_id}.py') try: - weights_path = os.path.join(RServer.get_server().base_dir, 'temp_weights.pth') - weight_file.save(weights_path) + with open(code_path, 'w') as code_file: + code_file.write(code) except Exception as e: - return RResponse.abort(500, f"Failed to save the weight file. {e}") - - # Load the weights from the file - try: - model.load_state_dict(torch.load(weights_path)) - except Exception as e: - return RResponse.abort(400, f"Failed to load the weights. {e}") + clear_model_temp_files(model_id) + return RResponse.abort(500, f"Failed to save the model definition. {e}") - # Validate the model - try: - val_model(model) - except Exception as e: - return RResponse.abort(400, f"The model is invalid. {e}") + # Initialize the model + try: + model = init_model(code_path, metadata.get('architecture')) + except Exception as e: + clear_model_temp_files(model_id) + return RResponse.abort(400, f"Failed to initialize the model. {e}") + + # Get the weight file and save it to a temporary location if it exists + if 'weight_file' in request.files: + weight_file = request.files.get('weight_file') + try: + weight_path = os.path.join(RServer.get_server().base_dir, f'{model_id}.pth') + weight_file.save(weight_path) + except Exception as e: + clear_model_temp_files(model_id) + return RResponse.abort(500, f"Failed to save the weight file. {e}") + + # Load the weights from the file + try: + model.load_state_dict(torch.load(weight_path)) + except Exception as e: + clear_model_temp_files(model_id) + return RResponse.abort(400, f"Failed to load the weights. {e}") + + # Validate the model + try: + val_model(model) + except Exception as e: + clear_model_temp_files(model_id) + return RResponse.abort(400, f"The model is invalid. {e}") - # TODO: generate a real model id - model_id = "abc123" + # Update the metadata + metadata['code_path'] = code_path + metadata['weight_path'] = weight_path + else: + print("Requested to upload a trained model") - # Save the model to the database + # Save the model's metadata to the database try: - save_model(model) + save_model(metadata) except Exception as e: return RResponse.abort(500, f"Failed to save the model. {e}") diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index 79d294c8..5281a2da 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -1,4 +1,6 @@ +import os import importlib +from objects.RServer import RServer def init_model(code_path, arch): @@ -16,12 +18,27 @@ def init_model(code_path, arch): raise e +def clear_model_temp_files(model_id): + """ Clear the temporary files associated with the model + """ + code_path = os.path.join(RServer.get_server().base_dir, f'{model_id}.py') + weight_path = os.path.join(RServer.get_server().base_dir, f'{model_id}.pth') + try: + if os.path.exists(code_path): + os.remove(code_path) + if os.path.exists(weight_path): + os.remove(weight_path) + except Exception as e: + print("Failed to clear the temporary files associated with the model.") + print(e) + raise e + + def val_model(model): """ Validate the model by running the model against a small portion of the validation dataset """ pass -# TODO: align the arguments with the actual database design -def save_model(model): +def save_model(metadata): pass From 3a6f6932b5f5758c6d1cc31c10e852f23d4e75bc Mon Sep 17 00:00:00 2001 From: Chonghan Date: Sun, 13 Aug 2023 18:57:49 -0700 Subject: [PATCH 10/58] refactor: implemented orm models --- back-end/{utils => database}/db.py | 0 back-end/{utils => database}/db_ops.py | 82 ++++++++++++------------- back-end/database/models.py | 76 +++++++++++++++++++++++ back-end/objects/RDataManager.py | 49 +++++---------- back-end/objects/RImageFolder.py | 2 +- back-end/server.py | 14 ++++- database/robustar-latest.db | Bin 77824 -> 86016 bytes database/robustar-v0.2.db | Bin 0 -> 77824 bytes docker-base/requirements.txt | 3 +- 9 files changed, 145 insertions(+), 81 deletions(-) rename back-end/{utils => database}/db.py (100%) rename back-end/{utils => database}/db_ops.py (58%) create mode 100644 back-end/database/models.py create mode 100644 database/robustar-v0.2.db diff --git a/back-end/utils/db.py b/back-end/database/db.py similarity index 100% rename from back-end/utils/db.py rename to back-end/database/db.py diff --git a/back-end/utils/db_ops.py b/back-end/database/db_ops.py similarity index 58% rename from back-end/utils/db_ops.py rename to back-end/database/db_ops.py index aa36ba00..7eb24797 100644 --- a/back-end/utils/db_ops.py +++ b/back-end/database/db_ops.py @@ -1,35 +1,37 @@ from typing import List, Tuple from sqlite3.dbapi2 import Cursor, Connection -# Note: commit should be done by the caller after flushing in-memory buffer +# Note: commit should be done by the caller after flushing in-memory buffer # to ensure consistency! + def db_insert(db_conn: Connection, table_name: str, keys: Tuple[str], values: Tuple): - assert(len(keys) == len(values)), "key and value array length not equal" + assert len(keys) == len(values), "key and value array length not equal" db_cursor = db_conn.cursor() template = "INSERT INTO {} ({}) values ({})".format( - table_name, - ",".join(keys), - ",".join(["?" for _ in values]) + table_name, ",".join(keys), ",".join(["?" for _ in values]) ) - + db_cursor.execute(template, values) - -def db_insert_many(db_conn: Connection, table_name: str, keys: Tuple[str], values: List[Tuple]): - if len(values) == 0: return - assert(len(keys) == len(values[0])), "key and value array does not contain the same number of attributes" + +def db_insert_many( + db_conn: Connection, table_name: str, keys: Tuple[str], values: List[Tuple] +): + if len(values) == 0: + return + assert len(keys) == len( + values[0] + ), "key and value array does not contain the same number of attributes" db_cursor = db_conn.cursor() template = "INSERT INTO {} ({}) values ({})".format( - table_name, - ",".join(keys), - ",".join(["?" for _ in keys]) + table_name, ",".join(keys), ",".join(["?" for _ in keys]) ) - + db_cursor.executemany(template, values) - + def db_select_all(db_conn: Connection, table_name: str) -> List[Tuple]: db_cursor = db_conn.cursor() @@ -40,7 +42,6 @@ def db_select_all(db_conn: Connection, table_name: str) -> List[Tuple]: db_cursor.execute(template) return db_cursor.fetchall() - def db_select_by_path(db_conn: Connection, table_name: str, path: str) -> List[Tuple]: @@ -53,35 +54,41 @@ def db_select_by_path(db_conn: Connection, table_name: str, path: str) -> List[T return db_cursor.fetchall() -def db_update_by_path(db_conn: Connection, table_name: str, path: str, keys: Tuple[str], values: Tuple): - assert(len(keys) == len(values)), "key and value array length not equal" +def db_update_by_path( + db_conn: Connection, table_name: str, path: str, keys: Tuple[str], values: Tuple +): + assert len(keys) == len(values), "key and value array length not equal" db_cursor = db_conn.cursor() template = "UPDATE {} SET {} WHERE path=?".format( - table_name, - ",".join(["{} = ?".format(key) for key in keys]) + table_name, ",".join(["{} = ?".format(key) for key in keys]) ) db_cursor.execute(template, values + (path,)) -def db_update_many_by_paths(db_conn: Connection, table_name: str, paths: List[str], keys: Tuple[str], values_list: List[Tuple]): +def db_update_many_by_paths( + db_conn: Connection, + table_name: str, + paths: List[str], + keys: Tuple[str], + values_list: List[Tuple], +): db_cursor = db_conn.cursor() template = "UPDATE {} SET {} WHERE path=?".format( - table_name, - ",".join(["{} = ?".format(key) for key in keys]) + table_name, ",".join(["{} = ?".format(key) for key in keys]) + ) + + db_cursor.executemany( + template, [values + (path,) for (values, path) in zip(values_list, paths)] ) - db_cursor.executemany(template, [values + (path,) for (values, path) in zip (values_list, paths)]) - def db_delete_by_path(db_conn: Connection, table_name: str, path: str): db_cursor = db_conn.cursor() - template = "DELETE FROM {} WHERE path=?".format( - table_name - ) + template = "DELETE FROM {} WHERE path=?".format(table_name) db_cursor.execute(template, (path,)) @@ -89,20 +96,15 @@ def db_delete_by_path(db_conn: Connection, table_name: str, path: str): def db_delete_all(db_conn: Connection, table_name: str): db_cursor = db_conn.cursor() - template = "DELETE FROM {}".format( - table_name - ) + template = "DELETE FROM {}".format(table_name) db_cursor.execute(template) - def db_count_all(db_conn: Connection, table_name: str): db_cursor = db_conn.cursor() - template = "SELECT COUNT (*) FROM {}".format( - table_name - ) + template = "SELECT COUNT (*) FROM {}".format(table_name) db_cursor.execute(template) return db_cursor.fetchone()[0] @@ -115,14 +117,8 @@ def db_get_cls_result(db_conn: Connection, table_name: str, correct: bool): classified = 1 else: classified = 2 - - template = "SELECT * from {} WHERE classified=?".format( - table_name - ) + + template = "SELECT * from {} WHERE classified=?".format(table_name) db_cursor.execute(template, classified) return db_cursor.fetchall() - - - - diff --git a/back-end/database/models.py b/back-end/database/models.py new file mode 100644 index 00000000..b1a3a981 --- /dev/null +++ b/back-end/database/models.py @@ -0,0 +1,76 @@ +from objects.RDataManager import db + + +class EvalResults(db.Model): + model_id = db.Column(db.BigInteger, primary_key=True) + img_path = db.Column(db.String, primary_key=True) + result = db.Column(db.Integer) + + +# Many-to-many relationship reference +# https://flask-sqlalchemy.palletsprojects.com/en/2.x/models/ +influ_rel = db.Table( + "influ_rel", + db.Column("model_id", db.BigInteger, db.ForeignKey("models.id"), primary_key=True), + db.Column( + "image_path", db.Integer, db.ForeignKey("test_set.path"), primary_key=True + ), + db.Column("influ_path", db.Integer), +) + + +class Models(db.Model): + id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) + name = db.Column(db.String) + description = db.Column(db.String) + architecture = db.Column(db.String) + tags = db.Column(db.String) + create_time = db.Column(db.DateTime) + weight_path = db.Column(db.String) + code_path = db.Column(db.String) + epoch = db.Column(db.Integer) + train_accuracy = db.Column(db.Double) + val_accuracy = db.Column(db.Double) + test_accuracy = db.Column(db.Double) + last_eval_on_dev_set = db.Column(db.DateTime) + last_eval_on_test_set = db.Column(db.DateTime) + + influences = db.relationship( + "TestSet", + secondary=influ_rel, + lazy="subquery", + backref=db.backref("models", lazy=True), + ) + + +class PairedSet(db.Model): + path = db.Column(db.String, primary_key=True) + train_path = db.Column(db.String) + + +class Proposed(db.Model): + path = db.Column(db.String, primary_key=True) + train_path = db.Column(db.String) + + +class TestSet(db.Model): + path = db.Column(db.String, primary_key=True) + + +class TrainSet(db.Model): + path = db.Column(db.String, primary_key=True) + paired_path = db.Column(db.String, db.ForeignKey("paired_set.id")) + + +class ValSet(db.Model): + path = db.Column(db.String, primary_key=True) + + +class Visuals(db.Model): + visual_path = db.Column(db.String, primary_key=True, unique=True) + + # TODO: Cannot set foreign key for image_path + # because this can refer to train/val/test table. + # Maybe consider merging those tables together? + image_path = db.Column(db.String) + model_id = db.Column(db.BigInteger, db.ForeignKey("models.id")) diff --git a/back-end/objects/RDataManager.py b/back-end/objects/RDataManager.py index fd70c0a6..53ddec2e 100644 --- a/back-end/objects/RDataManager.py +++ b/back-end/objects/RDataManager.py @@ -4,11 +4,21 @@ import os.path as osp import os from utils.path_utils import get_paired_path, split_path, to_unix -import torch +from flask import current_app as app +from flask_sqlalchemy import SQLAlchemy from torchvision import transforms from .RImageFolder import RAnnotationFolder, REvalImageFolder, RTrainImageFolder import torchvision.transforms.functional as transF +db = SQLAlchemy() + + +def init_db(self): + print("Initializing database ... ") + db.init_app(app) + with app.app_context(): + self.db.create_all() + # The data interface class RDataManager: @@ -17,9 +27,8 @@ class RDataManager: def __init__( self, - baseDir, - dataset_dir, - db_path, + baseDir: str, + dataset_dir: str, shuffle=True, image_size=32, image_padding="short_side", @@ -30,16 +39,15 @@ def __init__( # splits = ['train', 'test'] self.data_root = dataset_dir self.base_dir = baseDir - self.db_path = db_path + self.db_conn = db self.shuffle = shuffle self.image_size = image_size self.image_padding = image_padding self.class2label = class2label_mapping self._init_paths() - self._init_db() self._init_transforms() - self._init_data_records() + # self._init_data_records() def reload_influence_dict(self): if osp.exists(self.influence_file_path): @@ -76,25 +84,6 @@ def _init_transforms(self): ] ) - def _init_db(self): - if osp.exists(self.db_path): - print("DB already existed. Skipping initialization.") - # TODO: May need to check for concurrency issues. - self.db_conn = sqlite3.connect(self.db_path, check_same_thread=False) - self.db_cursor = self.db_conn.cursor() - return - - print("DB file not found. Initializing db...") - from utils.db import get_init_schema_str - - self.db_conn = sqlite3.connect(self.db_path, check_same_thread=False) - self.db_cursor = self.db_conn.cursor() - self.db_cursor.executescript(get_init_schema_str()) - - # Iterate through folders to construct database - - self.db_conn.commit() - def _init_paths(self): self.test_root = to_unix(osp.join(self.data_root, "test")) self.train_root = to_unix(osp.join(self.data_root, "train")) @@ -106,10 +95,7 @@ def _init_paths(self): self.influence_file_path = to_unix( osp.join(self.influence_root, "influence_images.pkl") ) - self.influence_log_path = to_unix( - osp.join(self.influence_root, "logs") - ) - + self.influence_log_path = to_unix(osp.join(self.influence_root, "logs")) def _init_data_records(self): self.testset: REvalImageFolder = REvalImageFolder( @@ -217,9 +203,6 @@ def _pull_item(self, index, buffer): def get_db_conn(self): return self.db_conn - def get_db_cursor(self): - return self.db_cursor - class SquarePad: """ diff --git a/back-end/objects/RImageFolder.py b/back-end/objects/RImageFolder.py index 60127b7a..cc52a230 100644 --- a/back-end/objects/RImageFolder.py +++ b/back-end/objects/RImageFolder.py @@ -10,7 +10,7 @@ from PIL import Image from io import BytesIO from sqlite3.dbapi2 import Connection -from utils.db_ops import * +from database.db_ops import * from collections import OrderedDict import os from os import path as osp diff --git a/back-end/server.py b/back-end/server.py index 54e7c5f2..e620aa5e 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -11,8 +11,10 @@ import argparse from flask_socketio import emit, SocketIO from apis import blueprints -import logging -log = logging.getLogger('werkzeug') +import logging +from database.models import * + +log = logging.getLogger("werkzeug") log.setLevel(logging.WARNING) @@ -137,10 +139,16 @@ def new_server_object(base_dir): ) """ SETUP DATA MANAGER """ + # Setup database + app.config["SQLALCHEMY_DATABASE_URI"] = db_path + from objects.RDataManager import db, init_db + + db.init_app(app) + init_db() + # Setup data manager data_manager = RDataManager( base_dir, dataset_dir, - db_path, shuffle=configs["shuffle"], image_size=image_size, image_padding=configs["image_padding"], diff --git a/database/robustar-latest.db b/database/robustar-latest.db index ffc01855bee2059b39c7789e0627fa692d968c14..63707bfce4d1c65b6a827f1c7c48fba45abab098 100644 GIT binary patch delta 1308 zcmZWoT~E_c7~bTg>$PWt!W54?^JlRlqs z6#v~Hd5}mikiNO~7c~3V7YWEd<^%hUeZva$FJ_0%(;q|cXf8AoYET+=68uhO$bZ4N z0hv7hd~i(M-d9ElZhYMTYSs`RtFYFPp{m>O$Z|28DP=(^vy{&Q8v`@eC@3;m%B|!I zCG?58xNBo|u?E3|OmX>crkDUS)Fn-68cL(y1w~D&Dh8B{mgd=Nh!wrNN7A5ZK*3Nj zTPe4eO@M8vRH}y16pgB98&>K-yY88=*^sILtIDoqu>?aCmAW8GQcDx1XW-6y87Gne zTcUavG@x#rMO6`_upQ8-3o_gibZ9uDJWJ>6f)(yA^4B7R+^ummL5x0Wexg4OJ1mmvY% zNSqF=V zbph=J!&Ad5O>h$ve)EbIXcq?C)tNBpn2uldsw@|B_sh6iO;ORH+>;-lVo($_Z!nP{ z`;a2IXw+{~hzu>_!=vbBD0yj{B)G{*zj?zJYIEQN9X31*qMxDZuGn-CJ*VdZ^@y-D zq-~+?u4wCY#sE^Oc7{=$T>xh{w@aGQ0ZYKX9kHe8Em75-VX&u;4hUwHnpkn0*X`X? z_X@YE#nh8{0(c#2(@Z%$3HyHHd)X7n^Rt7<48`VcRYJ;eV0 z6TwDh=B4G7#uugLz!?J0L9UJ=t_mTJPCl**aA5_Fg2a*xg)qk;XAj39hz&r?c5!KG#>T+OGCbyRZ_5c`>Ybd*W2w?8%)u_MsL0q@ z2noEz%%ap3kQ)l%EY8UXdDP%08OTBdFx1a8Fw_;{0AnsTad*YZ8+j#U5qjf|5NvK^ zPMC_xro3XxT#bxukkAf<_`WDHGY{;27=wNCWNuZs&2lnOvoOP2(~un)s4|oPaf^6y zH1aY-0yP(GY*}V8I1XVn3rZBiMDfL-1SAI0J*>|PN;sl06XK0vEH-@>kc=mcA8!a_ zv6e8hi;IghHoAkIR$P#iSpp`QP#gsoQqahRMf2o99$8Ht1+Z{@USe)4BBhy_Y9gXQ jlZj2-RvZ)sJP@^pU~2Pseolo&0Sb#01QrE2fXM~`)3E~@ diff --git a/database/robustar-v0.2.db b/database/robustar-v0.2.db new file mode 100644 index 0000000000000000000000000000000000000000..ffc01855bee2059b39c7789e0627fa692d968c14 GIT binary patch literal 77824 zcmeI&U2D@&7{KwwZGBJMZ5Kht*x8M&pq6ybJHa~39M;ZtoiY$Yw3!C_wl*6IisS?M zRrXE9i+vWsOWU+*?KEWtuMYo0laoWUJkReuCtWx_IxIV`El%6rrsaxlC99~a@>mE( zQDXh~YX2R)2FHuRL4U9Q(GF|Il=}ytwahPNC4Qx3UT40i?aZC@Pwg^YNL^~5w5KU0 z^)*>e{z!aF-i}`--h}-j`Vl|?0R#|0009I-1-!^|R9|0LJ=3+`HtbHf-D&sidaz&F zt(Jjg z)wBG_oz`jN-0a$oaU(kw`?!1l;GL%R-kuq}k&Wo3jj-Uymo|-YbJwxzP6@%hknQx zR$~zHXqTD_c+~lSR)mT*D+*G}@VQE`V*RBsD?u=XUsh}tQ z^T!=p;Mb-?ZaWdt_cp_ln1o;!Chg>Q{DvnPlamsj8W~QI;cyKj9F&+p7yFIqRQBWU z`I8@A^zZ4NGorI5#`u!`8ViDodD_BoVl;Y!3j&Q8r5}O_4fRPd!7Eh&)?jdO4aWYTh3%6&%ERMA2(Z8)1KU? zAME5Nvp;t$qCe8Z3UPS4;qMmu-yVbKc|{v+vV#pT1Q0*~0R#|0009ILKmY**5Rg?M zF-VN(e_4l@ybwSD0R#|0009ILKmY**5by<9|ECZTKmY**5I_I{1Q0*~0R#|`Ux4+0 z`Nx@3|CfJ^$q)eq5I_I{1Q0*~0R#|00D<2j;{%2O literal 0 HcmV?d00001 diff --git a/docker-base/requirements.txt b/docker-base/requirements.txt index 5a6012a1..89ae0b31 100644 --- a/docker-base/requirements.txt +++ b/docker-base/requirements.txt @@ -13,4 +13,5 @@ flasgger==0.9.5 protobuf=3.20.0 Flask-SocketIO==4.3.1 python-engineio==3.13.2 -python-socketio==4.6.0 \ No newline at end of file +python-socketio==4.6.0 +Flask-SQLAlchemy==3.0.5 \ No newline at end of file From 055a55cd3bc8e42a7923b78f6bf82009444f58ba Mon Sep 17 00:00:00 2001 From: Chonghan Date: Sun, 13 Aug 2023 19:55:39 -0700 Subject: [PATCH 11/58] fixed db model definition bugs --- back-end/database/models.py | 8 ++++---- back-end/objects/RDataManager.py | 7 ++----- back-end/requirements.txt | 10 +++++----- back-end/server.py | 5 +++-- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/back-end/database/models.py b/back-end/database/models.py index b1a3a981..75c63674 100644 --- a/back-end/database/models.py +++ b/back-end/database/models.py @@ -29,9 +29,9 @@ class Models(db.Model): weight_path = db.Column(db.String) code_path = db.Column(db.String) epoch = db.Column(db.Integer) - train_accuracy = db.Column(db.Double) - val_accuracy = db.Column(db.Double) - test_accuracy = db.Column(db.Double) + train_accuracy = db.Column(db.Float) + val_accuracy = db.Column(db.Float) + test_accuracy = db.Column(db.Float) last_eval_on_dev_set = db.Column(db.DateTime) last_eval_on_test_set = db.Column(db.DateTime) @@ -59,7 +59,7 @@ class TestSet(db.Model): class TrainSet(db.Model): path = db.Column(db.String, primary_key=True) - paired_path = db.Column(db.String, db.ForeignKey("paired_set.id")) + paired_path = db.Column(db.String, db.ForeignKey("paired_set.path")) class ValSet(db.Model): diff --git a/back-end/objects/RDataManager.py b/back-end/objects/RDataManager.py index 53ddec2e..29fde96f 100644 --- a/back-end/objects/RDataManager.py +++ b/back-end/objects/RDataManager.py @@ -1,10 +1,8 @@ import collections import pickle -import sqlite3 import os.path as osp import os from utils.path_utils import get_paired_path, split_path, to_unix -from flask import current_app as app from flask_sqlalchemy import SQLAlchemy from torchvision import transforms from .RImageFolder import RAnnotationFolder, REvalImageFolder, RTrainImageFolder @@ -13,11 +11,10 @@ db = SQLAlchemy() -def init_db(self): +def init_db(app): print("Initializing database ... ") - db.init_app(app) with app.app_context(): - self.db.create_all() + db.create_all() # The data interface diff --git a/back-end/requirements.txt b/back-end/requirements.txt index 59cc8fa8..08ba5788 100644 --- a/back-end/requirements.txt +++ b/back-end/requirements.txt @@ -1,5 +1,5 @@ flashtorch==0.1.3 -Flask==2.0.1 +Flask==2.2.5 importlib_resources==5.2.2 lib==3.0.0 matplotlib==3.4.2 @@ -10,12 +10,12 @@ torch==1.9.1 torchattacks==3.1.0 torchvision==0.10.1 webunit==1.3.10 -Werkzeug==2.0.1 +Werkzeug==2.3.6 tensorboard==2.6.0 scipy==1.7.1 flasgger==0.9.5 pytest==6.2.5 # subject to change -Flask-SocketIO==4.3.1 -python-engineio==3.13.2 -python-socketio==4.6.0 \ No newline at end of file +Flask-SocketIO==5.3.5 +python-engineio==4.5.1 +python-socketio==5.8.0 \ No newline at end of file diff --git a/back-end/server.py b/back-end/server.py index e620aa5e..a488cf3f 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -140,11 +140,12 @@ def new_server_object(base_dir): """ SETUP DATA MANAGER """ # Setup database - app.config["SQLALCHEMY_DATABASE_URI"] = db_path + db_conn_str = f"sqlite:///{db_path}" + app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str from objects.RDataManager import db, init_db db.init_app(app) - init_db() + init_db(app) # Setup data manager data_manager = RDataManager( base_dir, From 3a9f04bd2076a206429001363f6e79952315cddc Mon Sep 17 00:00:00 2001 From: Chonghan Date: Sun, 20 Aug 2023 09:30:04 -0700 Subject: [PATCH 12/58] change db types in function signatures --- back-end/objects/RDataManager.py | 2 +- back-end/objects/RImageFolder.py | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/back-end/objects/RDataManager.py b/back-end/objects/RDataManager.py index 29fde96f..da4c7a9b 100644 --- a/back-end/objects/RDataManager.py +++ b/back-end/objects/RDataManager.py @@ -44,7 +44,7 @@ def __init__( self._init_paths() self._init_transforms() - # self._init_data_records() + self._init_data_records() def reload_influence_dict(self): if osp.exists(self.influence_file_path): diff --git a/back-end/objects/RImageFolder.py b/back-end/objects/RImageFolder.py index cc52a230..0cdee1b2 100644 --- a/back-end/objects/RImageFolder.py +++ b/back-end/objects/RImageFolder.py @@ -6,12 +6,13 @@ ) from torchvision.datasets import DatasetFolder, ImageFolder from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Union -import PIL from PIL import Image from io import BytesIO from sqlite3.dbapi2 import Connection from database.db_ops import * from collections import OrderedDict +from flask_sqlalchemy import SQLAlchemy +from database.models import * import os from os import path as osp @@ -66,11 +67,11 @@ def get_slice(arr, start, end): SPLIT_TABLE_MAP = { - "train": "train_set", - "validation": "val_set", - "test": "test_set", - "annotated": "paired_set", - "proposed": "proposed", + "train": TrainSet, + "validation": ValSet, + "test": TestSet, + "annotated": PairedSet, + "proposed": Proposed, } @@ -105,7 +106,7 @@ def __init__( self, root: str, split: str, - db_conn: Connection, + db_conn: SQLAlchemy, transform: Optional[Callable] = None, target_transform: Optional[Callable] = None, loader: Callable[[str], Any] = default_loader, @@ -172,7 +173,7 @@ def __init__( self, root: str, split: str, - db_conn: Connection, + db_conn: SQLAlchemy, transform: Optional[Callable] = None, target_transform: Optional[Callable] = None, loader: Callable[[str], Any] = default_loader, @@ -251,7 +252,7 @@ def __init__( self, root: str, split: str, - db_conn: Connection, + db_conn: SQLAlchemy, transform: Optional[Callable] = None, target_transform: Optional[Callable] = None, loader: Callable[[str], Any] = default_loader, @@ -367,7 +368,7 @@ def __init__( root: str, train_root: str, split: str, - db_conn: Connection, + db_conn: SQLAlchemy, transform: Optional[Callable] = None, target_transform: Optional[Callable] = None, loader: Callable[[str], Any] = default_loader, From 41967caaa973cd0b4f913e5e8332bc5f98c0b341 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 27 Aug 2023 17:46:12 -0400 Subject: [PATCH 13/58] Enhance UploadModel API with ID assignment and architecture metadata --- back-end/apis/model.py | 27 +++++++++++++++++++-------- back-end/utils/model_utils.py | 8 ++++---- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 76d82a6d..18aab725 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -1,9 +1,13 @@ +import os import json import string -import os +import uuid import torch +import io +import contextlib from flask import request from flask import Blueprint +from datetime import datetime from utils.model_utils import * from objects.RResponse import RResponse from objects.RServer import RServer @@ -103,13 +107,12 @@ def UploadModel(): if 'code' in request.form: print("Requested to upload a new model") - # Get the model id - model_id = metadata.get('model_id') + # Generate a uuid for the model + model_id = str(uuid.uuid4()) # Get the model definition code and save it to a temporary file code = request.form.get('code') - # TODO: discuss with team the path to save the definition and weight file - code_path = os.path.join(RServer.get_server().base_dir, f'{model_id}.py') + code_path = os.path.join(RServer.get_server().base_dir, 'generated', f'{model_id}.py') try: with open(code_path, 'w') as code_file: code_file.write(code) @@ -119,7 +122,7 @@ def UploadModel(): # Initialize the model try: - model = init_model(code_path, metadata.get('architecture')) + model = init_model(code_path, metadata.get('name')) except Exception as e: clear_model_temp_files(model_id) return RResponse.abort(400, f"Failed to initialize the model. {e}") @@ -128,7 +131,7 @@ def UploadModel(): if 'weight_file' in request.files: weight_file = request.files.get('weight_file') try: - weight_path = os.path.join(RServer.get_server().base_dir, f'{model_id}.pth') + weight_path = os.path.join(RServer.get_server().base_dir, 'generated', f'{model_id}.pth') weight_file.save(weight_path) except Exception as e: clear_model_temp_files(model_id) @@ -148,11 +151,19 @@ def UploadModel(): clear_model_temp_files(model_id) return RResponse.abort(400, f"The model is invalid. {e}") - # Update the metadata + # Update the code path and weight path info in the metadata metadata['code_path'] = code_path metadata['weight_path'] = weight_path + + # Save the model's architecture to the metadata + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + print(model) + metadata['architecture'] = buffer.getvalue() else: print("Requested to upload a trained model") + # Get the model id + model_id = metadata.get('model_id') # Save the model's metadata to the database try: diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index 5281a2da..8b8e542d 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -3,14 +3,14 @@ from objects.RServer import RServer -def init_model(code_path, arch): +def init_model(code_path, name): """ Initialize the model by importing the class named arch in the file specified by code_path """ try: spec = importlib.util.spec_from_file_location("model_def", code_path) model_def = importlib.util.module_from_spec(spec) spec.loader.exec_module(model_def) - model = getattr(model_def, arch)() + model = getattr(model_def, name)() return model except Exception as e: print("Failed to initialize the model.") @@ -21,8 +21,8 @@ def init_model(code_path, arch): def clear_model_temp_files(model_id): """ Clear the temporary files associated with the model """ - code_path = os.path.join(RServer.get_server().base_dir, f'{model_id}.py') - weight_path = os.path.join(RServer.get_server().base_dir, f'{model_id}.pth') + code_path = os.path.join(RServer.get_server().base_dir, 'generated', f'{model_id}.py') + weight_path = os.path.join(RServer.get_server().base_dir, 'generated', f'{model_id}.pth') try: if os.path.exists(code_path): os.remove(code_path) From e06554050f9a37716ced4ff9560456f8316afa34 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 27 Aug 2023 22:41:18 -0400 Subject: [PATCH 14/58] Complete `val_model` function implementation --- back-end/utils/model_utils.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index 8b8e542d..3fa419a5 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -1,6 +1,12 @@ import os import importlib from objects.RServer import RServer +from utils.predict import get_image_prediction + + +class DummyModelWrapper: + def __init__(self, model): + self.model = model def init_model(code_path, name): @@ -37,7 +43,23 @@ def clear_model_temp_files(model_id): def val_model(model): """ Validate the model by running the model against a small portion of the validation dataset """ - pass + # Get at most 10 samples from the validation dataset + data_manager = RServer.get_data_manager() + dataset = data_manager.validationset + samples = dataset.samples[:10] + + # Create a dummy model wrapper to pass to the predict function + dummy_model_wrapper = DummyModelWrapper(model) + dummy_model_wrapper.model.eval() + + # Run the model against the samples + for img_path, label in samples: + get_image_prediction( + dummy_model_wrapper, + img_path, + data_manager.image_size, + argmax=False, + ) def save_model(metadata): From 6a54e9b77507f372bc520bd077898a9caa04fa44 Mon Sep 17 00:00:00 2001 From: Chonghan Date: Mon, 28 Aug 2023 01:07:27 -0700 Subject: [PATCH 15/58] fix: new database operations with SQLAlchemy partially working --- back-end/database/__init__.py | 0 back-end/database/db_init.py | 9 + back-end/database/db_ops.py | 1 + back-end/database/{models.py => model.py} | 22 ++- back-end/objects/RDataManager.py | 49 +++-- back-end/objects/RImageFolder.py | 218 ++++++++++++---------- back-end/server.py | 11 +- back-end/utils/edit_utils.py | 1 - 8 files changed, 182 insertions(+), 129 deletions(-) create mode 100644 back-end/database/__init__.py create mode 100644 back-end/database/db_init.py rename back-end/database/{models.py => model.py} (81%) diff --git a/back-end/database/__init__.py b/back-end/database/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/back-end/database/db_init.py b/back-end/database/db_init.py new file mode 100644 index 00000000..739cc5ee --- /dev/null +++ b/back-end/database/db_init.py @@ -0,0 +1,9 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + + +def init_db(app): + print("Initializing database ... ") + with app.app_context(): + db.create_all() diff --git a/back-end/database/db_ops.py b/back-end/database/db_ops.py index 7eb24797..d6633044 100644 --- a/back-end/database/db_ops.py +++ b/back-end/database/db_ops.py @@ -1,5 +1,6 @@ from typing import List, Tuple from sqlite3.dbapi2 import Cursor, Connection +from flask_sqlalchemy import SQLAlchemy # Note: commit should be done by the caller after flushing in-memory buffer # to ensure consistency! diff --git a/back-end/database/models.py b/back-end/database/model.py similarity index 81% rename from back-end/database/models.py rename to back-end/database/model.py index 75c63674..6a9dacf1 100644 --- a/back-end/database/models.py +++ b/back-end/database/model.py @@ -1,4 +1,4 @@ -from objects.RDataManager import db +from .db_init import db class EvalResults(db.Model): @@ -13,7 +13,7 @@ class EvalResults(db.Model): "influ_rel", db.Column("model_id", db.BigInteger, db.ForeignKey("models.id"), primary_key=True), db.Column( - "image_path", db.Integer, db.ForeignKey("test_set.path"), primary_key=True + "image_path", db.Integer, db.ForeignKey("test_set_image.path"), primary_key=True ), db.Column("influ_path", db.Integer), ) @@ -36,34 +36,38 @@ class Models(db.Model): last_eval_on_test_set = db.Column(db.DateTime) influences = db.relationship( - "TestSet", + "TestSetImage", secondary=influ_rel, lazy="subquery", backref=db.backref("models", lazy=True), ) -class PairedSet(db.Model): +class PairedSetImage(db.Model): path = db.Column(db.String, primary_key=True) train_path = db.Column(db.String) + label = db.Column(db.Integer) -class Proposed(db.Model): +class ProposedImage(db.Model): path = db.Column(db.String, primary_key=True) train_path = db.Column(db.String) -class TestSet(db.Model): +class TestSetImage(db.Model): path = db.Column(db.String, primary_key=True) + label = db.Column(db.Integer) -class TrainSet(db.Model): +class TrainSetImage(db.Model): path = db.Column(db.String, primary_key=True) - paired_path = db.Column(db.String, db.ForeignKey("paired_set.path")) + paired_path = db.Column(db.String, db.ForeignKey("paired_set_image.path")) + label = db.Column(db.Integer) -class ValSet(db.Model): +class ValSetImage(db.Model): path = db.Column(db.String, primary_key=True) + label = db.Column(db.Integer) class Visuals(db.Model): diff --git a/back-end/objects/RDataManager.py b/back-end/objects/RDataManager.py index da4c7a9b..3585c0ba 100644 --- a/back-end/objects/RDataManager.py +++ b/back-end/objects/RDataManager.py @@ -1,21 +1,19 @@ import collections +from flask import Flask import pickle import os.path as osp import os from utils.path_utils import get_paired_path, split_path, to_unix -from flask_sqlalchemy import SQLAlchemy from torchvision import transforms -from .RImageFolder import RAnnotationFolder, REvalImageFolder, RTrainImageFolder +from flask_sqlalchemy import SQLAlchemy +from .RImageFolder import ( + RAnnotationFolder, + REvalImageFolder, + RTrainImageFolder, + db_is_all_tables_empty, +) import torchvision.transforms.functional as transF -db = SQLAlchemy() - - -def init_db(app): - print("Initializing database ... ") - with app.app_context(): - db.create_all() - # The data interface class RDataManager: @@ -26,6 +24,8 @@ def __init__( self, baseDir: str, dataset_dir: str, + db_conn: SQLAlchemy, + app: Flask, shuffle=True, image_size=32, image_padding="short_side", @@ -36,15 +36,18 @@ def __init__( # splits = ['train', 'test'] self.data_root = dataset_dir self.base_dir = baseDir - self.db_conn = db + self.db_conn = db_conn self.shuffle = shuffle self.image_size = image_size self.image_padding = image_padding self.class2label = class2label_mapping + self.app = app self._init_paths() self._init_transforms() - self._init_data_records() + + with app.app_context(): + self._init_data_records() def reload_influence_dict(self): if osp.exists(self.influence_file_path): @@ -95,11 +98,26 @@ def _init_paths(self): self.influence_log_path = to_unix(osp.join(self.influence_root, "logs")) def _init_data_records(self): + # Check if we should recreate all db tables: + should_reindex = db_is_all_tables_empty(self.db_conn) + if should_reindex: + print("Re-populating database with latest file system state") + else: + print("DB already exists. Not populating data.") + self.testset: REvalImageFolder = REvalImageFolder( - self.test_root, "test", self.db_conn, transform=self.transforms + self.test_root, + "test", + self.db_conn, + transform=self.transforms, + should_reindex=should_reindex, ) self.trainset: RTrainImageFolder = RTrainImageFolder( - self.train_root, "train", self.db_conn, transform=self.transforms + self.train_root, + "train", + self.db_conn, + transform=self.transforms, + should_reindex=should_reindex, ) if not os.path.exists(self.validation_root): self.validationset: REvalImageFolder = self.testset @@ -109,6 +127,7 @@ def _init_data_records(self): "validation", self.db_conn, transform=self.transforms, + should_reindex=should_reindex, ) self._init_folders() @@ -127,6 +146,7 @@ def _init_data_records(self): split="proposed", db_conn=self.db_conn, transform=self.transforms, + should_reindex=should_reindex, ) ## TODO: Commented this line out for now, because if the user changed the training set, ## The cache will be wrong, and the user has to manually delete the annotated folder, which @@ -141,6 +161,7 @@ def _init_data_records(self): split="annotated", db_conn=self.db_conn, transform=self.transforms, + should_reindex=should_reindex, ) self.split_dict = { diff --git a/back-end/objects/RImageFolder.py b/back-end/objects/RImageFolder.py index 0cdee1b2..8fedcbc4 100644 --- a/back-end/objects/RImageFolder.py +++ b/back-end/objects/RImageFolder.py @@ -8,11 +8,9 @@ from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Union from PIL import Image from io import BytesIO -from sqlite3.dbapi2 import Connection -from database.db_ops import * from collections import OrderedDict from flask_sqlalchemy import SQLAlchemy -from database.models import * +from database.model import * import os from os import path as osp @@ -66,13 +64,15 @@ def get_slice(arr, start, end): return arr[start:end] -SPLIT_TABLE_MAP = { - "train": TrainSet, - "validation": ValSet, - "test": TestSet, - "annotated": PairedSet, - "proposed": Proposed, -} +def db_is_all_tables_empty(db_conn: SQLAlchemy): + for table in db_conn.Model.metadata.tables.values(): + row_count = ( + db_conn.session.query(db_conn.func.count()).select_from(table).scalar() + ) + if row_count > 0: + return False + + return True class RImageFolder(DatasetFolder): @@ -90,7 +90,8 @@ class RImageFolder(DatasetFolder): loader (callable, optional): A function to load an image given its path. is_valid_file (callable, optional): A function that takes path of an Image file and check if the file is a valid file (used to check of corrupt files) - force_reindex: force the train, val and test sets to be re-indexed and written to db + should_reindex: shall we re-create all metadata for the database to reflect the latest + images in the file system Attributes: classes (list): List of the class names sorted alphabetically. @@ -111,7 +112,7 @@ def __init__( target_transform: Optional[Callable] = None, loader: Callable[[str], Any] = default_loader, is_valid_file: Optional[Callable[[str], bool]] = None, - force_reindex: bool = False, + should_reindex: bool = False, class2label: dict[str, str] = None, ): @@ -136,27 +137,40 @@ def __init__( self.root = root self.split = split self.db_conn = db_conn - self.table_name = SPLIT_TABLE_MAP[split] self.class2label = class2label self.imgs = self.samples # Return if no need to rebuild the index; - if not force_reindex and db_count_all(db_conn, SPLIT_TABLE_MAP[split]) > 0: - return - - if split == "train": - keys = ("path", "paired_path") - values = [(path, None) for path, _ in self.imgs] - elif split == "validation" or split == "test": - keys = ("path", "classified") - values = [(path, self.CLS_NONE) for path, _ in self.imgs] + if should_reindex: + self._populate_db() + + def _populate_db(self): + # with self.db_conn.session.begin(): + if "train" in self.split: + print( + "Populating the db table train_set_image for split {}".format( + self.split + ) + ) + images = [ + TrainSetImage(path=path, paired_path=None, label=label) + for path, label in self.imgs + ] + elif "validation" in self.split: + print( + "Populating the db table val_set_image for split {}".format(self.split) + ) + images = [ValSetImage(path=path, label=label) for path, label in self.imgs] + elif "test" in self.split: + print( + "Populating the db table test_set_image for split {}".format(self.split) + ) + images = [TestSetImage(path=path, label=label) for path, label in self.imgs] else: # Do not re-index for other splits - print("Not populating the db table {}".format(self.table_name)) + print(f"No database table to be populated for split {self.split}") return - - print("Populating the db table {} for split {}".format(self.table_name, split)) - db_insert_many(db_conn, self.table_name, keys, values) - db_conn.commit() + self.db_conn.session.add_all(images) + self.db_conn.session.commit() def get_image_list(self, start=None, end=None): if start is not None and len(self.samples) <= start: @@ -178,7 +192,7 @@ def __init__( target_transform: Optional[Callable] = None, loader: Callable[[str], Any] = default_loader, is_valid_file: Optional[Callable[[str], bool]] = None, - force_reindex: bool = False, + should_reindex: bool = False, class2label: dict[str, str] = None, ): @@ -190,7 +204,7 @@ def __init__( target_transform=target_transform, loader=loader, is_valid_file=is_valid_file, - force_reindex=force_reindex, + should_reindex=should_reindex, class2label=class2label, ) @@ -198,26 +212,6 @@ def __init__( self._init_buffer() self._populate_buffer() - def update_paired_data(self, train_paths: List[str], paired_paths: List[str]): - train_paths = [to_unix(train_path) for train_path in train_paths] - paired_paths = [to_unix(paired_path) for paired_path in paired_paths] - - # 1. update database - db_update_many_by_paths( - self.db_conn, - self.table_name, - train_paths, - keys=("paired_path",), - values_list=[(paired_path,) for paired_path in paired_paths], - ) - - # 2. update buffer - for train_path, paired_path in zip(train_paths, paired_paths): - self.train2paired[train_path] = paired_path - - # 3. commit - self.db_conn.commit() - def get_paired_from_train(self, train_path): train_path = to_unix(train_path) if train_path in self.train2paired: @@ -236,10 +230,10 @@ def _init_buffer(self): self.train2paired = dict() def _populate_buffer(self): - for path, paired_path in db_select_all(self.db_conn, self.table_name): - path = to_unix(path) - if paired_path is not None: - self.train2paired[path] = paired_path + for train_set_image in TrainSetImage.query.all(): + path = to_unix(train_set_image.path) + if train_set_image.paired_path is not None: + self.train2paired[path] = train_set_image.paired_path class REvalImageFolder(RImageFolder): @@ -257,7 +251,7 @@ def __init__( target_transform: Optional[Callable] = None, loader: Callable[[str], Any] = default_loader, is_valid_file: Optional[Callable[[str], bool]] = None, - force_reindex: bool = False, + should_reindex: bool = False, class2label: dict[str, str] = None, ): @@ -269,7 +263,7 @@ def __init__( target_transform=target_transform, loader=loader, is_valid_file=is_valid_file, - force_reindex=force_reindex, + should_reindex=should_reindex, class2label=class2label, ) @@ -278,27 +272,23 @@ def __init__( self._populate_buffers() self._init_next_records() - def post_records(self, correct_records: List[Tuple[str, int]], incorrect_records): + def post_records( + self, + correct_records: List[Tuple[str, int]], + incorrect_records: List[Tuple[str, int]], + ): correct_paths = [to_unix(record[0]) for record in correct_records] incorrect_paths = [to_unix(record[0]) for record in incorrect_records] # 1. update db with the result - correct_value_list = [(self.CLS_CORRECT,) for _ in correct_paths] - incorrect_value_list = [(self.CLS_INCORRECT,) for _ in incorrect_paths] - db_update_many_by_paths( - self.db_conn, - self.table_name, - correct_paths, - ("classified",), - correct_value_list, - ) - db_update_many_by_paths( - self.db_conn, - self.table_name, - incorrect_paths, - ("classified",), - incorrect_value_list, - ) + # TODO: get model ID here + for path in correct_paths: + result = EvalResults(model_id=0, img_path=path, result=self.CLS_CORRECT) + self.db_conn.session.merge(result) + + for path in incorrect_paths: + result = EvalResults(model_id=0, img_path=path, result=self.CLS_INCORRECT) + self.db_conn.session.merge(result) # 3. update buffer self.buffer_correct = correct_records @@ -308,7 +298,7 @@ def post_records(self, correct_records: List[Tuple[str, int]], incorrect_records self._init_next_records() # 5. commit - self.db_conn.commit() + self.db_conn.session.commit() def get_next_record(self, path, correct: bool): next_record = self.next_correct if correct else self.next_incorrect @@ -338,20 +328,28 @@ def get_record(self, correct: bool, start=None, end=None): return [p[0] for p in get_slice(buffer, start, end)] def _populate_buffers(self): - db_data = db_select_all(self.db_conn, self.table_name) - # database and sample data must contain same number of images - # assert(len(db_data) == len(self.samples)) # TODO: for test use - - for (imgpath, label), (path, classified) in zip(self.imgs, db_data): - # paths must be in the same order, i.e., folder and database - # must be consistent - assert imgpath == path + if "validation" in self.split: + table_to_query = ValSetImage + elif "test" in self.split: + table_to_query = TestSetImage + else: + raise ValueError(f"Split {self.split} is not supported!") + + correct_results = ( + self.db_conn.session.query(table_to_query) + .join(EvalResults, EvalResults.img_path == table_to_query.path) + .filter(EvalResults.result == 0) + .all() + ) + self.buffer_correct = [(res.path, res.label) for res in correct_results] - path = to_unix(path) - if classified == self.CLS_CORRECT: - self.buffer_correct.append((path, label)) - elif classified == self.CLS_INCORRECT: - self.buffer_incorrect.append((path, label)) + incorrect_results = ( + self.db_conn.session.query(table_to_query.path) + .join(EvalResults, EvalResults.img_path == table_to_query.path) + .filter(EvalResults.result == 1) + .all() + ) + self.buffer_incorrect = [(res.path, res.label) for res in incorrect_results] class RAnnotationFolder(RImageFolder): @@ -373,6 +371,7 @@ def __init__( target_transform: Optional[Callable] = None, loader: Callable[[str], Any] = default_loader, is_valid_file: Optional[Callable[[str], bool]] = None, + should_reindex: bool = False, class2label: dict[str, str] = None, ): @@ -390,6 +389,7 @@ def __init__( target_transform=target_transform, loader=loader, is_valid_file=is_valid_file, + should_reindex=should_reindex, class2label=class2label, ) @@ -412,7 +412,13 @@ def __init__( def remove_image(self, path): path = to_unix(path) # 1. delete the paired image in database... - db_delete_by_path(self.db_conn, self.table_name, path) + image_to_delete = PairedSetImage.query.get(path) + if image_to_delete: + self.db_conn.delete(image_to_delete) + print(f"Image deleted. Path: {image_to_delete}") + else: + print(f"Image failed to delete. Path: {image_to_delete}") + return # 2. create an empty image placeholder os.remove(path) @@ -432,12 +438,12 @@ def remove_image(self, path): del self._paired2train[path] # 5. commit - self.db_conn.commit() + self.db_conn.session.commit() return True def clear_images(self): # 1. delete all paired images in database... - db_delete_all(self.db_conn, self.table_name) + self.db_conn.session.query(PairedSetImage).delete() # 2. create new paired folder # No need to explicitly do this, just let paired images be there. @@ -449,11 +455,12 @@ def clear_images(self): self.prev_records = dict() # 4. commit - self.db_conn.commit() + self.db_conn.session.commit() def save_annotated_image( self, train_path, + trainset: RTrainImageFolder, image_data: Union[Image.Image, bytes], image_height=None, image_width=None, @@ -462,15 +469,18 @@ def save_annotated_image( paired_path = get_paired_path(train_path, self.train_root, self.root) # 1. insert new paired path to db if image not already annotated - if train_path not in self._train2paired: - db_insert( - self.db_conn, - self.table_name, - ("path", "train_path"), - (paired_path, train_path), - ) + # if train_path not in self._train2paired: + new_paired_image = PairedSetImage(path=paired_path, train_path=train_path) + self.db_conn.session.merge(new_paired_image) + + # 2. update corresponding training image + train_image_to_update = TrainSetImage.query.filter_by(path=train_path) + train_image_to_update.paired_path = paired_path + + # 2. update buffer + trainset.train2paired[train_path] = paired_path - # 2. dump the image file to disk + # 3. dump the image file to disk if isinstance(image_data, Image.Image): # If image_data is PIL Image image_data.save(paired_path, format="png") else: # If image_data is an array of bytes @@ -478,17 +488,17 @@ def save_annotated_image( raise ValueError("must specify image height and width") self._dump_image_data(paired_path, image_data, image_height, image_width) - # 3. update buffers + # 4. update buffers self._train2paired[train_path] = paired_path self._paired2train[paired_path] = train_path - # 4. update next record datastructures + # 5. update next record datastructures if self.last_record: self.next_records[self.last_record] = paired_path self.prev_records[paired_path] = self.last_record self.last_record = paired_path - # 5. commit + # 6. commit self.db_conn.commit() def get_paired_by_train(self, train_path): @@ -565,8 +575,10 @@ def _init_root_dir(self): create_empty_paired_image(mirrored_img_path) def _populate_buffers(self): - for paired_path, train_path in db_select_all(self.db_conn, self.table_name): - paired_path, train_path = to_unix(paired_path), to_unix(train_path) + for paired_data in PairedSetImage.query.all(): + paired_path, train_path = to_unix(paired_data.paired_path), to_unix( + paired_data.train_path + ) self._train2paired[train_path] = paired_path self._paired2train[paired_path] = train_path diff --git a/back-end/server.py b/back-end/server.py index a488cf3f..5e797d40 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -12,7 +12,7 @@ from flask_socketio import emit, SocketIO from apis import blueprints import logging -from database.models import * +from database.model import * log = logging.getLogger("werkzeug") log.setLevel(logging.WARNING) @@ -128,6 +128,8 @@ def new_server_object(base_dir): configs["image_size"] if expected_input_shape is None else expected_input_shape ) + print("Server initializing...") + """ CREATE SERVER """ server = RServer.create_server( configs=configs, @@ -142,7 +144,7 @@ def new_server_object(base_dir): # Setup database db_conn_str = f"sqlite:///{db_path}" app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str - from objects.RDataManager import db, init_db + from database.db_init import db, init_db db.init_app(app) init_db(app) @@ -150,6 +152,8 @@ def new_server_object(base_dir): data_manager = RDataManager( base_dir, dataset_dir, + db, + app, shuffle=configs["shuffle"], image_size=image_size, image_padding=configs["image_padding"], @@ -176,6 +180,8 @@ def new_server_object(base_dir): ) RServer.set_auto_annotator(annotator) + print("Performing server consistency checks...") + # Check file state consistency precheck() @@ -201,6 +207,7 @@ def create_app(): print("Absolute basedir is {}".format(basedir)) new_server_object(basedir) + print("Server started") return RServer.get_server().get_flask_app() diff --git a/back-end/utils/edit_utils.py b/back-end/utils/edit_utils.py index 2e419a63..0da99c04 100644 --- a/back-end/utils/edit_utils.py +++ b/back-end/utils/edit_utils.py @@ -68,7 +68,6 @@ def save_edit(split, image_path, image_data, image_height, image_width): dataManager.pairedset.save_annotated_image( train_img_path, image_data, image_height, image_width ) - dataManager.trainset.update_paired_data([train_img_path], [paired_img_path]) refresh_img_data(paired_img_path) From bb88202223791ba08520faebbc9ea4eb6d50e45b Mon Sep 17 00:00:00 2001 From: Chonghan Date: Wed, 30 Aug 2023 18:35:02 -0700 Subject: [PATCH 16/58] most functionalities working; not passing test cases --- back-end/apis/edit.py | 4 ++++ back-end/database/db_init.py | 4 +++- back-end/database/model.py | 1 - back-end/objects/RImageFolder.py | 27 +++++++++++++++++---------- back-end/server.py | 1 - back-end/tests/test_app.py | 27 ++++++++++++++------------- back-end/utils/edit_utils.py | 26 +++++++++++++++----------- back-end/utils/image_utils.py | 4 ++-- back-end/utils/test.py | 10 +++++++--- 9 files changed, 62 insertions(+), 42 deletions(-) diff --git a/back-end/apis/edit.py b/back-end/apis/edit.py index 38d0c637..86a9a894 100644 --- a/back-end/apis/edit.py +++ b/back-end/apis/edit.py @@ -1,4 +1,5 @@ import binascii +import traceback from apis.api_configs import PARAM_NAME_IMAGE_PATH from objects.RServer import RServer @@ -75,10 +76,13 @@ def api_user_edit(split): save_edit(split, path, decoded, h, w) return RResponse.ok("Success!") except binascii.Error: + traceback.print_exc() RResponse.abort(400, "Broken image, fail to decode") except ValueError as e: + traceback.print_exc() RResponse.abort(400, str(e)) except Exception as e: + traceback.print_exc() RResponse.abort(500, str(e)) diff --git a/back-end/database/db_init.py b/back-end/database/db_init.py index 739cc5ee..1cc83e18 100644 --- a/back-end/database/db_init.py +++ b/back-end/database/db_init.py @@ -1,9 +1,11 @@ from flask_sqlalchemy import SQLAlchemy -db = SQLAlchemy() +db: SQLAlchemy = None def init_db(app): + global db + db = SQLAlchemy() print("Initializing database ... ") with app.app_context(): db.create_all() diff --git a/back-end/database/model.py b/back-end/database/model.py index 6a9dacf1..83570ea2 100644 --- a/back-end/database/model.py +++ b/back-end/database/model.py @@ -46,7 +46,6 @@ class Models(db.Model): class PairedSetImage(db.Model): path = db.Column(db.String, primary_key=True) train_path = db.Column(db.String) - label = db.Column(db.Integer) class ProposedImage(db.Model): diff --git a/back-end/objects/RImageFolder.py b/back-end/objects/RImageFolder.py index 8fedcbc4..02645ecd 100644 --- a/back-end/objects/RImageFolder.py +++ b/back-end/objects/RImageFolder.py @@ -397,6 +397,13 @@ def __init__( del self.imgs del self.samples + if "annotated" in self.split: + self.db_model = PairedSetImage + elif "proposed" in self.split: + self.db_model = ProposedImage + else: + raise NotImplemented + # init buffers self._init_buffers() @@ -412,7 +419,7 @@ def __init__( def remove_image(self, path): path = to_unix(path) # 1. delete the paired image in database... - image_to_delete = PairedSetImage.query.get(path) + image_to_delete = self.db_model.query.get(path) if image_to_delete: self.db_conn.delete(image_to_delete) print(f"Image deleted. Path: {image_to_delete}") @@ -443,7 +450,7 @@ def remove_image(self, path): def clear_images(self): # 1. delete all paired images in database... - self.db_conn.session.query(PairedSetImage).delete() + self.db_conn.session.query(self.db_model).delete() # 2. create new paired folder # No need to explicitly do this, just let paired images be there. @@ -470,15 +477,15 @@ def save_annotated_image( # 1. insert new paired path to db if image not already annotated # if train_path not in self._train2paired: - new_paired_image = PairedSetImage(path=paired_path, train_path=train_path) + new_paired_image = self.db_model(path=paired_path, train_path=train_path) self.db_conn.session.merge(new_paired_image) # 2. update corresponding training image - train_image_to_update = TrainSetImage.query.filter_by(path=train_path) - train_image_to_update.paired_path = paired_path + if self.db_model == PairedSetImage: + train_image_to_update = TrainSetImage.query.filter_by(path=train_path) + train_image_to_update.paired_path = paired_path - # 2. update buffer - trainset.train2paired[train_path] = paired_path + trainset.train2paired[train_path] = paired_path # 3. dump the image file to disk if isinstance(image_data, Image.Image): # If image_data is PIL Image @@ -499,7 +506,7 @@ def save_annotated_image( self.last_record = paired_path # 6. commit - self.db_conn.commit() + self.db_conn.session.commit() def get_paired_by_train(self, train_path): """ @@ -575,8 +582,8 @@ def _init_root_dir(self): create_empty_paired_image(mirrored_img_path) def _populate_buffers(self): - for paired_data in PairedSetImage.query.all(): - paired_path, train_path = to_unix(paired_data.paired_path), to_unix( + for paired_data in self.db_model.query.all(): + paired_path, train_path = to_unix(paired_data.path), to_unix( paired_data.train_path ) self._train2paired[train_path] = paired_path diff --git a/back-end/server.py b/back-end/server.py index 5e797d40..4223a39c 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -12,7 +12,6 @@ from flask_socketio import emit, SocketIO from apis import blueprints import logging -from database.model import * log = logging.getLogger("werkzeug") log.setLevel(logging.WARNING) diff --git a/back-end/tests/test_app.py b/back-end/tests/test_app.py index 4280b75d..3675b470 100644 --- a/back-end/tests/test_app.py +++ b/back-end/tests/test_app.py @@ -15,22 +15,23 @@ @pytest.fixture() def app(request): basedir = request.config.getoption("basedir") + app = None + try: + _set_up(basedir) - _set_up(basedir) + if app is None: + app, _ = start_flask_app() + server = new_server_object(basedir) + server = RServer.get_server() + app = server.get_flask_app() - app, _ = start_flask_app() - server = new_server_object(basedir) - server = RServer.get_server() - app = server.get_flask_app() + app.config["TESTING"] = True + yield app + app.config["TESTING"] = False - app.config["TESTING"] = True - yield app - app.config["TESTING"] = False - - RServer.get_data_manager().get_db_conn().close() - # due to unavailability of close_connection() in fs.py - - _clean_up(basedir) + except Exception as e: + _clean_up(basedir) + raise e time.sleep(0.1) diff --git a/back-end/utils/edit_utils.py b/back-end/utils/edit_utils.py index 0da99c04..19dff88d 100644 --- a/back-end/utils/edit_utils.py +++ b/back-end/utils/edit_utils.py @@ -3,7 +3,7 @@ from PIL import Image from objects.RServer import RServer from io import BytesIO -from utils.image_utils import refresh_img_data +from utils.image_utils import cache_image import threading from objects.RTask import RTask, TaskType import time @@ -53,7 +53,7 @@ def save_edit(split, image_path, image_data, image_height, image_width): """ train_img_path, paired_img_path = get_train_and_paired_path(split, image_path) - dataManager = RServer.get_data_manager() + data_manager = RServer.get_data_manager() if not os.path.exists(train_img_path): raise ValueError("invalid image path") @@ -65,10 +65,10 @@ def save_edit(split, image_path, image_data, image_height, image_width): to_save.save(paired_img_path, format="png") - dataManager.pairedset.save_annotated_image( - train_img_path, image_data, image_height, image_width + data_manager.pairedset.save_annotated_image( + train_img_path, data_manager.trainset, image_data, image_height, image_width ) - refresh_img_data(paired_img_path) + cache_image(paired_img_path) def propose_edit(split, image_path, return_image=False): @@ -96,7 +96,9 @@ def propose_edit(split, image_path, return_image=False): pil_image = RServer.get_auto_annotator().annotate_single( train_img_path, dataManager.image_size ) - dataManager.proposedset.save_annotated_image(train_img_path, pil_image) + dataManager.proposedset.save_annotated_image( + train_img_path, dataManager.trainset, pil_image + ) else: if return_image: pil_image = Image.open(proposed_path) @@ -110,11 +112,11 @@ def start_auto_annotate(split, start: int, end: int): if split != "train": raise NotImplementedError("Auto annotation only supported for train split") - dataManager = RServer.get_data_manager() + data_manager = RServer.get_data_manager() # -1 means annotate till the end if end == -1: - end = len(dataManager.trainset) - end = min(end, len(dataManager.trainset)) + end = len(data_manager.trainset) + end = min(end, len(data_manager.trainset)) if start == end: return @@ -122,12 +124,14 @@ def auto_annotate_thread(split, start, end): task = RTask(TaskType.AutoAnnotate, end - start) starttime = time.time() - for train_path in dataManager.trainset.get_image_list(start, end): + for train_path in data_manager.trainset.get_image_list(start, end): # Propose edit for this image proposed_image_path, pil_image = propose_edit(split, train_path, True) # Save the image to paired data folder - dataManager.pairedset.save_annotated_image(train_path, pil_image) + data_manager.pairedset.save_annotated_image( + train_path, data_manager.trainset, pil_image + ) task_update_res = task.update() if not task_update_res: diff --git a/back-end/utils/image_utils.py b/back-end/utils/image_utils.py index c99102b3..100b4ee7 100644 --- a/back-end/utils/image_utils.py +++ b/back-end/utils/image_utils.py @@ -101,7 +101,7 @@ def get_img_Data(dataset_img_path): if osp.exists(normal_path): if normal_path not in dataset_file_buffer: - refresh_img_data(normal_path) + cache_image(normal_path) image_data = dataset_file_buffer[normal_path] return image_data else: @@ -125,7 +125,7 @@ def image_to_base64_string(path: str) -> str: return image_data -def refresh_img_data(path: str): +def cache_image(path: str): data_manager = RServer.get_data_manager() dataset_file_queue = data_manager.dataset_file_queue dataset_file_buffer = data_manager.dataset_file_buffer diff --git a/back-end/utils/test.py b/back-end/utils/test.py index e381b628..fc8d6cfe 100644 --- a/back-end/utils/test.py +++ b/back-end/utils/test.py @@ -1,10 +1,10 @@ -''' +""" Author: Chonghan Chen (paulcccccch@gmail.com) ----- Last Modified: Friday, 10th March 2023 4:48:58 pm Modified By: Chonghan Chen (paulcccccch@gmail.com) ----- -''' +""" import threading from objects.RServer import RServer from utils.path_utils import to_unix @@ -29,7 +29,11 @@ def __init__(self, split): def run(self): print("Starting testing thread") try: - self.start_test_thread() + # TODO(chonghan): If we don't get a flask context here we are going to + # get error. Are we going to have concurrency issues if we use the + # same context as the main thread? + with RServer.get_server().get_flask_app().app_context(): + self.start_test_thread() except Exception as e: raise e finally: From 01571c5e1cc28688d610fce0e870796f215eca94 Mon Sep 17 00:00:00 2001 From: Chonghan Date: Thu, 31 Aug 2023 00:32:25 -0700 Subject: [PATCH 17/58] test cases passing --- back-end/database/db_init.py | 4 +--- back-end/tests/test_app.py | 17 ++++++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/back-end/database/db_init.py b/back-end/database/db_init.py index 1cc83e18..739cc5ee 100644 --- a/back-end/database/db_init.py +++ b/back-end/database/db_init.py @@ -1,11 +1,9 @@ from flask_sqlalchemy import SQLAlchemy -db: SQLAlchemy = None +db = SQLAlchemy() def init_db(app): - global db - db = SQLAlchemy() print("Initializing database ... ") with app.app_context(): db.create_all() diff --git a/back-end/tests/test_app.py b/back-end/tests/test_app.py index 3675b470..66500318 100644 --- a/back-end/tests/test_app.py +++ b/back-end/tests/test_app.py @@ -10,28 +10,31 @@ from utils.path_utils import to_unix PARAM_NAME_IMAGE_PATH = "image_url" +flask_app = None @pytest.fixture() def app(request): + global flask_app basedir = request.config.getoption("basedir") - app = None try: _set_up(basedir) - if app is None: - app, _ = start_flask_app() + if flask_app is None: + flask_app, _ = start_flask_app() server = new_server_object(basedir) server = RServer.get_server() - app = server.get_flask_app() + flask_app = server.get_flask_app() - app.config["TESTING"] = True - yield app - app.config["TESTING"] = False + flask_app.config["TESTING"] = True + yield flask_app + flask_app.config["TESTING"] = False except Exception as e: _clean_up(basedir) raise e + finally: + _clean_up(basedir) time.sleep(0.1) From fb906b3d1c53c67f871cfa22c25ccb36710fad83 Mon Sep 17 00:00:00 2001 From: Chonghan Date: Thu, 31 Aug 2023 00:47:33 -0700 Subject: [PATCH 18/58] chore: update docker image version; include SQLAlchemy dependency --- Dockerfile | 2 +- README.md | 4 ++-- back-end/requirements.txt | 1 + docker-base/requirements.txt | 6 +++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index ba6a8c37..5e27e572 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM paulcccccch/robustar-base:base-0.2.0 +FROM paulcccccch/robustar-base:base-0.3.0 # Default VCUDA is 11.1 ARG VCUDA=11.1 COPY . /Robustar2/ diff --git a/README.md b/README.md index fc8530aa..0d40046f 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ docker build . -t /: For example, ``` -docker build . -t paulcccccch/robustar-base:base-0.2.0 +docker build . -t paulcccccch/robustar-base:base-0.3.0 ``` --- @@ -92,7 +92,7 @@ docker push /: For example, ``` -docker push paulcccccch/robustar-base:base-0.2.0 +docker push paulcccccch/robustar-base:base-0.3.0 ``` ## Dev setup diff --git a/back-end/requirements.txt b/back-end/requirements.txt index 08ba5788..e2d132c9 100644 --- a/back-end/requirements.txt +++ b/back-end/requirements.txt @@ -15,6 +15,7 @@ tensorboard==2.6.0 scipy==1.7.1 flasgger==0.9.5 pytest==6.2.5 +Flask-SQLAlchemy==3.0.5 # subject to change Flask-SocketIO==5.3.5 python-engineio==4.5.1 diff --git a/docker-base/requirements.txt b/docker-base/requirements.txt index 89ae0b31..a1073f3c 100644 --- a/docker-base/requirements.txt +++ b/docker-base/requirements.txt @@ -1,16 +1,16 @@ -Flask==2.0.1 +Flask==2.2.5 importlib_resources==5.2.2 lib==3.0.0 matplotlib==3.4.2 numpy==1.21.2 Pillow==8.4.0 webunit==1.3.10 -Werkzeug==2.0.1 +Werkzeug==2.2.2 tensorboard==2.6.0 scipy==1.7.1 setuptools==58.0.4 flasgger==0.9.5 -protobuf=3.20.0 +protobuf==3.20.0 Flask-SocketIO==4.3.1 python-engineio==3.13.2 python-socketio==4.6.0 From 56bc6a85d692f95e8f8fa12d7eb7ec54c13488b0 Mon Sep 17 00:00:00 2001 From: Chonghan Date: Thu, 31 Aug 2023 01:22:10 -0700 Subject: [PATCH 19/58] feat: implemented model operations --- back-end/objects/RModelWrapper.py | 32 ++++++++++++++++++++++++++++++- back-end/server.py | 1 + 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/back-end/objects/RModelWrapper.py b/back-end/objects/RModelWrapper.py index 47996ad3..b7b0dab2 100644 --- a/back-end/objects/RModelWrapper.py +++ b/back-end/objects/RModelWrapper.py @@ -2,6 +2,8 @@ import torchvision import os from threading import Lock +from flask_sqlalchemy import SQLAlchemy +from database.model import * IMAGENET_OUTPUT_SIZE = 1000 @@ -16,9 +18,13 @@ "alexnet": 227, } +# TODO(Chonghan): Change this class to RModelManager later. class RModelWrapper: - def __init__(self, network_type, net_path, device, pretrained, num_classes): + def __init__( + self, db_conn, network_type, net_path, device, pretrained, num_classes + ): # self.device = torch.device(device) + self.db_conn: SQLAlchemy = db_conn if pretrained: assert ( num_classes == IMAGENET_OUTPUT_SIZE @@ -112,3 +118,27 @@ def release_model(self): self._lock.acquire() self._model_available = True self._lock.release() + + def create_model(self, fields: dict): + # TODO: Need to validate fields, dump model definition to a file, + # etc. Either do these here or somewhere else + + model = Models(**fields) + self.db_conn.session.add(model) + self.db_conn.session.commit() + + def list_models(self) -> Models: + return Models.query.all() + + def delete_model_by_name(self, name): + model_to_delete = Models.query.filter_by(name=name).first() + if model_to_delete: + db.session.delete(model_to_delete) + db.session.commit() + else: + print( + f"Attempting to delete a model that does not exist. Model name: {name}" + ) + + def get_model_by_name(self, name): + return Models.query.filter_by(name=name).first() diff --git a/back-end/server.py b/back-end/server.py index 4223a39c..3d982f4a 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -162,6 +162,7 @@ def new_server_object(base_dir): """ SETUP MODEL """ model = RModelWrapper( + db_conn=db, network_type=network_type, net_path=to_unix(os.path.join(ckpt_dir, configs["weight_to_load"])), device=configs["device"], From a53030413fd3696ce65f327131d5b1daa12781b3 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 3 Sep 2023 13:45:42 -0400 Subject: [PATCH 20/58] Update save path for model definition and checkpoint files to /generated/models --- back-end/apis/model.py | 9 +++++++-- back-end/utils/model_utils.py | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 18aab725..c392889e 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -103,6 +103,11 @@ def UploadModel(): # Get the model's metadata metadata = json.loads(request.form.get('metadata')) + # Check if the folder for saving models exists, if not, create it + models_dir = os.path.join(RServer.get_server().base_dir, 'generated', 'models') + if not os.path.exists(models_dir): + os.makedirs(models_dir) + # If it is a new model, validate it and update code_path and weight_path in its metadata if 'code' in request.form: print("Requested to upload a new model") @@ -112,7 +117,7 @@ def UploadModel(): # Get the model definition code and save it to a temporary file code = request.form.get('code') - code_path = os.path.join(RServer.get_server().base_dir, 'generated', f'{model_id}.py') + code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{model_id}.py') try: with open(code_path, 'w') as code_file: code_file.write(code) @@ -131,7 +136,7 @@ def UploadModel(): if 'weight_file' in request.files: weight_file = request.files.get('weight_file') try: - weight_path = os.path.join(RServer.get_server().base_dir, 'generated', f'{model_id}.pth') + weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{model_id}.pth') weight_file.save(weight_path) except Exception as e: clear_model_temp_files(model_id) diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index 3fa419a5..d49f97d0 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -27,8 +27,8 @@ def init_model(code_path, name): def clear_model_temp_files(model_id): """ Clear the temporary files associated with the model """ - code_path = os.path.join(RServer.get_server().base_dir, 'generated', f'{model_id}.py') - weight_path = os.path.join(RServer.get_server().base_dir, 'generated', f'{model_id}.pth') + code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{model_id}.py') + weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{model_id}.pth') try: if os.path.exists(code_path): os.remove(code_path) From 8a60f0c75dab8bd118ea49e3dd12656cd2be7d80 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 3 Sep 2023 16:06:44 -0400 Subject: [PATCH 21/58] Refactor 'UploadModel' API for enhanced model handling - Improve API to handle predefined models. - Remove logic handling post-training model uploads. - Utilize `create_model` from `RModelWrapper` for saving. --- back-end/apis/model.py | 134 +++++++++++++++++++++------------- back-end/utils/model_utils.py | 61 +++++++++++++--- 2 files changed, 133 insertions(+), 62 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index c392889e..20d86738 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -49,7 +49,6 @@ def DeleteModel(): def UploadModel(): """ Should also accept (optionally) a model weight file as argument - After training a model, should do the same --- tags: - model @@ -108,81 +107,112 @@ def UploadModel(): if not os.path.exists(models_dir): os.makedirs(models_dir) - # If it is a new model, validate it and update code_path and weight_path in its metadata + print("Requested to upload a new model") + + # TODO: Discuss with the team about the naming of the model related files if the id is set to autoincrement + # Generate a uuid for the model saving + saving_id = str(uuid.uuid4()) + + metadata_4_save = {'name': None, + 'description': None, + 'architecture': None, + 'tags': None, + 'creat_time': None, + 'weight_path': None, + 'code_path': None, + 'epoch': None, + 'train_accuracy': None, + 'val_accuracy': None, + 'test_accuracy': None, + 'last_eval_on_dev_set': None, + 'last_eval_on_test_set': None + } + + # Get the model name + name = metadata.get('name') + + # If the model is custom(i.e. it has code definition) if 'code' in request.form: - print("Requested to upload a new model") - - # Generate a uuid for the model - model_id = str(uuid.uuid4()) - # Get the model definition code and save it to a temporary file code = request.form.get('code') - code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{model_id}.py') + code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.py') try: with open(code_path, 'w') as code_file: code_file.write(code) except Exception as e: - clear_model_temp_files(model_id) + clear_model_temp_files(saving_id) return RResponse.abort(500, f"Failed to save the model definition. {e}") - # Initialize the model + # Initialize the custom model + try: + model = init_custom_model(code_path, name) + except Exception as e: + clear_model_temp_files(saving_id) + return RResponse.abort(400, f"Failed to initialize the custom model. {e}") + else: # If the model is predefined + pretrained = metadata.get('pretrained') + num_classes = metadata.get('num_classes') try: - model = init_model(code_path, metadata.get('name')) + model = init_predefined_model(name, pretrained, num_classes) except Exception as e: - clear_model_temp_files(model_id) - return RResponse.abort(400, f"Failed to initialize the model. {e}") - - # Get the weight file and save it to a temporary location if it exists - if 'weight_file' in request.files: - weight_file = request.files.get('weight_file') - try: - weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{model_id}.pth') - weight_file.save(weight_path) - except Exception as e: - clear_model_temp_files(model_id) - return RResponse.abort(500, f"Failed to save the weight file. {e}") - - # Load the weights from the file - try: - model.load_state_dict(torch.load(weight_path)) - except Exception as e: - clear_model_temp_files(model_id) - return RResponse.abort(400, f"Failed to load the weights. {e}") - - # Validate the model + return RResponse.abort(400, f"Failed to initialize the predefined model. {e}") + + # Get the weight file and save it to a temporary location if it exists + if 'weight_file' in request.files: + weight_file = request.files.get('weight_file') try: - val_model(model) + weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.pth') + weight_file.save(weight_path) except Exception as e: - clear_model_temp_files(model_id) - return RResponse.abort(400, f"The model is invalid. {e}") - - # Update the code path and weight path info in the metadata - metadata['code_path'] = code_path - metadata['weight_path'] = weight_path - - # Save the model's architecture to the metadata - buffer = io.StringIO() - with contextlib.redirect_stdout(buffer): - print(model) - metadata['architecture'] = buffer.getvalue() - else: - print("Requested to upload a trained model") - # Get the model id - model_id = metadata.get('model_id') + clear_model_temp_files(saving_id) + return RResponse.abort(500, f"Failed to save the weight file. {e}") + + # Load the weights from the file + try: + model.load_state_dict(torch.load(weight_path)) + except Exception as e: + clear_model_temp_files(saving_id) + return RResponse.abort(400, f"Failed to load the weights. {e}") + + # Validate the model + try: + val_model(model) + except Exception as e: + clear_model_temp_files(saving_id) + return RResponse.abort(400, f"The model is invalid. {e}") + + # Update the metadata for saving + metadata_4_save['name'] = name + metadata_4_save['description'] = metadata.get('description') + metadata_4_save['tags'] = metadata.get('tags') + metadata_4_save['create_time'] = datetime.datetime.now() + metadata_4_save['code_path'] = code_path if 'code' in request.form else None + metadata_4_save['weight_path'] = weight_path if 'weight_file' in request.files else None + metadata_4_save['epoch'] = 0 + metadata_4_save['train_accuracy'] = None + metadata_4_save['val_accuracy'] = None + metadata_4_save['test_accuracy'] = None + metadata_4_save['last_eval_on_dev_set'] = None + metadata_4_save['last_eval_on_test_set'] = None + + # Save the model's architecture to the metadata + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + print(model) + metadata_4_save['architecture'] = buffer.getvalue() # Save the model's metadata to the database try: - save_model(metadata) + RServer.get_model_wrapper().create_model(metadata_4_save) except Exception as e: return RResponse.abort(500, f"Failed to save the model. {e}") # Set the current model to the newly uploaded model try: - SetCurrModel(model_id) + SetCurrModel(saving_id) except Exception as e: return RResponse.abort(500, f"Failed to set the current model. {e}") - # TODO: return real data as specified in the docstring return RResponse.ok('Success') diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index d49f97d0..6d98b4fd 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -1,16 +1,61 @@ import os import importlib +import torch +import torchvision from objects.RServer import RServer from utils.predict import get_image_prediction +IMAGENET_OUTPUT_SIZE = 1000 + +PREDEFINED_MODELS = ['ResNet18', 'ResNet34', 'ResNet50', 'ResNet101', 'ResNet152', 'mobilenet-v2', 'ResNet18-32x32', + 'AlexNet'] + + class DummyModelWrapper: def __init__(self, model): - self.model = model + model = model -def init_model(code_path, name): - """ Initialize the model by importing the class named arch in the file specified by code_path +def init_predefined_model(name, pretrained, num_classes): + """ Initialize the predefined model with the specified name + """ + # Check if the model name is valid + if name not in PREDEFINED_MODELS: + raise Exception(f"Predefined model name {name} not recognized.") + + # If the model is pretrained, it should have the same number of classes as the ImageNet model + if pretrained and num_classes != IMAGENET_OUTPUT_SIZE: + raise Exception(f"Pretrained model is supposed to have {IMAGENET_OUTPUT_SIZE} classes as output.") + + if name == "ResNet18": + model = torchvision.models.resnet18(pretrained=pretrained, num_classes=num_classes) + elif name == "ResNet34": + model = torchvision.models.resnet34(pretrained=pretrained, num_classes=num_classes) + elif name == "ResNet50": + model = torchvision.models.resnet50(pretrained=pretrained, num_classes=num_classes) + elif name == "ResNet101": + model = torchvision.models.resnet101(pretrained=pretrained, num_classes=num_classes) + elif name == "ResNet152": + model = torchvision.models.resnet152(pretrained=pretrained, num_classes=num_classes) + elif name == "mobilenet-v2": + model = torchvision.models.mobilenet_v2(pretrained=pretrained, num_classes=num_classes) + elif name == "ResNet18-32x32": + model = torchvision.models.ResNet(torchvision.models.resnet.BasicBlock, [2, 2, 2, 2], num_classes=num_classes) + model.conv1 = torch.nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) + model.maxpool = torch.nn.MaxPool2d(kernel_size=3, stride=1, padding=1) + # TODO: discuss with the team the best way to design the UI for this model which is predefined but + # with no pretrained weights available + if pretrained: + print("Pretrained ResNet18-32x32 is not available.") + elif name == "AlexNet": + model = torchvision.models.alexnet(pretrained=pretrained, num_classes=num_classes) + + return model + + +def init_custom_model(code_path, name): + """ Initialize the custom model by importing the class with the specified name in the file specified by code_path """ try: spec = importlib.util.spec_from_file_location("model_def", code_path) @@ -24,11 +69,11 @@ def init_model(code_path, name): raise e -def clear_model_temp_files(model_id): +def clear_model_temp_files(saving_id): """ Clear the temporary files associated with the model """ - code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{model_id}.py') - weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{model_id}.pth') + code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.py') + weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.pth') try: if os.path.exists(code_path): os.remove(code_path) @@ -60,7 +105,3 @@ def val_model(model): data_manager.image_size, argmax=False, ) - - -def save_model(metadata): - pass From a42333646cfa44e6adf7000e723849bdd0d7407c Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 3 Sep 2023 17:14:34 -0400 Subject: [PATCH 22/58] Migrate from importlib_resources to importlib.resources --- back-end/modules/visualize_module/flashtorch_/utils/imagenet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/back-end/modules/visualize_module/flashtorch_/utils/imagenet.py b/back-end/modules/visualize_module/flashtorch_/utils/imagenet.py index c1937158..5868fdc6 100644 --- a/back-end/modules/visualize_module/flashtorch_/utils/imagenet.py +++ b/back-end/modules/visualize_module/flashtorch_/utils/imagenet.py @@ -6,7 +6,7 @@ import json from collections.abc import Mapping -from importlib_resources import path +from importlib.resources import path from . import resources From 8e9fe4b3ab7f0a7af52cf2ab3a2eff04c6f2b11f Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 3 Sep 2023 17:16:22 -0400 Subject: [PATCH 23/58] Refresh requirements.txt with updated packge versions --- back-end/requirements.txt | Bin 385 -> 2486 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/back-end/requirements.txt b/back-end/requirements.txt index 59cc8fa88774e8f1d3a3c71bd1044b13c192a158..1fa2fd1f272874106d30d1c314b4e5a61d4eabb6 100644 GIT binary patch literal 2486 zcmZ{mOK%fF5QOK9#7}V)+ev@}2QEk;5J)*7PRLn5V`pvbbzVOnsIP0svqp|qPVDXO z>gw+5+5P)xly1^4tx_*-(o=e`tDnx&g+Ax$b$XRXsnNBS54J%X%7TZjH))Z^`c(44 zelE*E7d}Ma2zim}v`iCWWb~`>!d?l#N^@D8wAQt1^IXcqne0V$Bc!Q5=5gL~dDCXJ z-&}ZGF#-=i*OA$h3HvVWJ@H!W*PZUc^7+7#%+%l-tFTt80mQeuPyKJAzA89_G720# z=mB}J4jy$>x0hG9$EAyvI!~6|BM|zXE>ktum9k|X?hY)6k!LFFNR`cWb#dOwgC2Pv zA6qZi;^>S}Y`t{dW}=Uu(zmEQ&*ZYrw^skqMt5%1ebJf!EUdF&-xI&R{O5s2ZY#xI zKJD34e2!v9HxcX3<@wB_ua&IpgY`0MHZlsWg<{>%iG2S~^I&$3_<`Ni45@ge2z2il z)XLOfj@l|}CnP3Z#dmTM=SiZ?h46NPTelO!KW~*aHHglBmT#SIm3UfveEf|V%p|;%CD!Wjrh?kG{yF@RZKE7>F$*^vy{h!K2*3$_lhr@OjhgRtFX4( zk+HlUga;>>TM=|1d+f#GTHaG3m6>D}Qcoifw4UdZvApYG-36aU9jsdva+Pi~StD6( zD8?Q{6Vr$#vtOpi16S8u3zhhC8V*z=Y9;oR9znLA@z|&WwsfR9Gs#(7C0@?(UH4Z} z;X~L^q}h9R8}Xw0wKTa`pYnVM(M|J!ANyVl6FUg7%m~Gbs@Iy!UK!D8_AdPZ3)Y;B zg|Y2t(v+)u5~I10YVC>lVWYEr>-7rzu(TYi4HKAtyIYt@SH_U%)KAVG@sL+Su-P5!t;0AIZ3FcJfq4~ zGrB8=U$ObU#GB@B(=#-cS!8Y8t6C~Xxmo-D&PF$y+gMd&BmOG-;fXb|8J_wtv3?W- zl#ub9IV;&QJO$Wpo+!gWrOSgB)o%qqq65o#=hz=k2%k}Yvv^kgMi;EChVs_*4|vdR As{jB1 literal 385 zcmXv~yKciU4BYh<20BOZGi?U01v<4rhmMA<(za?@GW2MWetk(h0l4sZcRX^mD$dCV zJp+`ol8tyJWC5rpwZg2M56POL4;Xz88Y4g_Ygr2d0UBbJVpVCgo@tD9 z&U(cmJCk@TkxKD}Vfn*6J#It;hUi|*+gxQn9rmi>F{a7z=jvuaZZe6ccybY z@l?b-{Gd)n@`aw2oA7wm>C@{4)7v{h%Zphzdv`)N8HdL2ksJEL9gjPm@s>0E15S;2 A&;S4c From 450a07ea5bc417cca9f098f6fc945825d49719b1 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 10 Sep 2023 15:19:49 -0400 Subject: [PATCH 24/58] Correct some mistakes to let 'UploadModel' API work for basic custom model upload - Change `Models` table id type from BigInteger to Integer for autoincrement - Correct typos --- back-end/apis/__init__.py | 2 ++ back-end/apis/model.py | 4 ++-- back-end/database/model.py | 2 +- back-end/utils/model_utils.py | 5 +++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/back-end/apis/__init__.py b/back-end/apis/__init__.py index c835958d..5559ea05 100644 --- a/back-end/apis/__init__.py +++ b/back-end/apis/__init__.py @@ -6,6 +6,7 @@ from .test import test_api from .config import config_api from .task import task_api +from .model import model_api blueprints = [ edit_api, @@ -16,4 +17,5 @@ test_api, config_api, task_api, + model_api, ] diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 20d86738..64364fae 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -117,7 +117,7 @@ def UploadModel(): 'description': None, 'architecture': None, 'tags': None, - 'creat_time': None, + 'create_time': None, 'weight_path': None, 'code_path': None, 'epoch': None, @@ -185,7 +185,7 @@ def UploadModel(): metadata_4_save['name'] = name metadata_4_save['description'] = metadata.get('description') metadata_4_save['tags'] = metadata.get('tags') - metadata_4_save['create_time'] = datetime.datetime.now() + metadata_4_save['create_time'] = datetime.now() metadata_4_save['code_path'] = code_path if 'code' in request.form else None metadata_4_save['weight_path'] = weight_path if 'weight_file' in request.files else None metadata_4_save['epoch'] = 0 diff --git a/back-end/database/model.py b/back-end/database/model.py index 83570ea2..2e9109aa 100644 --- a/back-end/database/model.py +++ b/back-end/database/model.py @@ -20,7 +20,7 @@ class EvalResults(db.Model): class Models(db.Model): - id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) name = db.Column(db.String) description = db.Column(db.String) architecture = db.Column(db.String) diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index 6d98b4fd..2ceb7d26 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -12,9 +12,11 @@ 'AlexNet'] +# TODO: Use real model wrapper instead of dummy model wrapper class DummyModelWrapper: def __init__(self, model): - model = model + self.model = model + self.device = RServer.get_model_wrapper().device def init_predefined_model(name, pretrained, num_classes): @@ -92,7 +94,6 @@ def val_model(model): data_manager = RServer.get_data_manager() dataset = data_manager.validationset samples = dataset.samples[:10] - # Create a dummy model wrapper to pass to the predict function dummy_model_wrapper = DummyModelWrapper(model) dummy_model_wrapper.model.eval() From f4c8c27713be767d1a87437b9e2c98eb57a16542 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 10 Sep 2023 16:24:30 -0400 Subject: [PATCH 25/58] Fix type errors of var `pretrained` and `num_classes` --- back-end/apis/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 64364fae..9f1b9eae 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -150,8 +150,8 @@ def UploadModel(): clear_model_temp_files(saving_id) return RResponse.abort(400, f"Failed to initialize the custom model. {e}") else: # If the model is predefined - pretrained = metadata.get('pretrained') - num_classes = metadata.get('num_classes') + pretrained = bool(int(metadata.get('pretrained'))) + num_classes = int(metadata.get('num_classes')) try: model = init_predefined_model(name, pretrained, num_classes) except Exception as e: From cfd78fa65289336b014a0590fa2a3ea77b2d63ed Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 10 Sep 2023 16:47:03 -0400 Subject: [PATCH 26/58] Save the weight of pretrained predefined model to a local file --- back-end/apis/model.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 9f1b9eae..9c51e618 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -113,6 +113,9 @@ def UploadModel(): # Generate a uuid for the model saving saving_id = str(uuid.uuid4()) + code_path = None + weight_path = None + metadata_4_save = {'name': None, 'description': None, 'architecture': None, @@ -174,6 +177,16 @@ def UploadModel(): clear_model_temp_files(saving_id) return RResponse.abort(400, f"Failed to load the weights. {e}") + # If the model is predefined and pretrained, save the weights + if code_path is None: + if pretrained: + try: + weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.pth') + torch.save(model.state_dict(), weight_path) + except Exception as e: + clear_model_temp_files(saving_id) + return RResponse.abort(500, f"Failed to save the weight file. {e}") + # Validate the model try: val_model(model) @@ -186,8 +199,8 @@ def UploadModel(): metadata_4_save['description'] = metadata.get('description') metadata_4_save['tags'] = metadata.get('tags') metadata_4_save['create_time'] = datetime.now() - metadata_4_save['code_path'] = code_path if 'code' in request.form else None - metadata_4_save['weight_path'] = weight_path if 'weight_file' in request.files else None + metadata_4_save['code_path'] = code_path + metadata_4_save['weight_path'] = weight_path metadata_4_save['epoch'] = 0 metadata_4_save['train_accuracy'] = None metadata_4_save['val_accuracy'] = None From f8ea4c3620262c6ae4c4933365d4c6b30f4c0d43 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 10 Sep 2023 17:46:19 -0400 Subject: [PATCH 27/58] Raise an exception if pretrained weight is required for "ResNet18-32x32" --- back-end/utils/model_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index 2ceb7d26..5934de4b 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -46,10 +46,8 @@ def init_predefined_model(name, pretrained, num_classes): model = torchvision.models.ResNet(torchvision.models.resnet.BasicBlock, [2, 2, 2, 2], num_classes=num_classes) model.conv1 = torch.nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) model.maxpool = torch.nn.MaxPool2d(kernel_size=3, stride=1, padding=1) - # TODO: discuss with the team the best way to design the UI for this model which is predefined but - # with no pretrained weights available if pretrained: - print("Pretrained ResNet18-32x32 is not available.") + raise Exception("Pretrained ResNet18-32x32 is not available.") elif name == "AlexNet": model = torchvision.models.alexnet(pretrained=pretrained, num_classes=num_classes) From 30a4d8d4f0ba3f60be879129f3eb2f9e03a43f44 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 10 Sep 2023 17:56:31 -0400 Subject: [PATCH 28/58] Save `num_classes` of predefined models into local files for later restoration --- back-end/apis/model.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 9c51e618..02517b09 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -113,7 +113,7 @@ def UploadModel(): # Generate a uuid for the model saving saving_id = str(uuid.uuid4()) - code_path = None + code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.py') weight_path = None metadata_4_save = {'name': None, @@ -138,7 +138,6 @@ def UploadModel(): if 'code' in request.form: # Get the model definition code and save it to a temporary file code = request.form.get('code') - code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.py') try: with open(code_path, 'w') as code_file: code_file.write(code) @@ -157,6 +156,8 @@ def UploadModel(): num_classes = int(metadata.get('num_classes')) try: model = init_predefined_model(name, pretrained, num_classes) + with open(code_path, 'w') as code_file: + code_file.write(f"num_classes = {num_classes}") except Exception as e: return RResponse.abort(400, f"Failed to initialize the predefined model. {e}") @@ -178,14 +179,13 @@ def UploadModel(): return RResponse.abort(400, f"Failed to load the weights. {e}") # If the model is predefined and pretrained, save the weights - if code_path is None: - if pretrained: - try: - weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.pth') - torch.save(model.state_dict(), weight_path) - except Exception as e: - clear_model_temp_files(saving_id) - return RResponse.abort(500, f"Failed to save the weight file. {e}") + if 'code' not in request.form and pretrained: + try: + weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.pth') + torch.save(model.state_dict(), weight_path) + except Exception as e: + clear_model_temp_files(saving_id) + return RResponse.abort(500, f"Failed to save the weight file. {e}") # Validate the model try: From 4088512c48fba4741719cc7392648d2cb4c45c11 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 10 Sep 2023 22:56:03 -0400 Subject: [PATCH 29/58] Save current model weights to a local file if the weight file is not provided --- back-end/apis/model.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 02517b09..7aabe682 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -177,9 +177,7 @@ def UploadModel(): except Exception as e: clear_model_temp_files(saving_id) return RResponse.abort(400, f"Failed to load the weights. {e}") - - # If the model is predefined and pretrained, save the weights - if 'code' not in request.form and pretrained: + else: # If the weight file is not provided, save the current weights to a temporary location try: weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.pth') torch.save(model.state_dict(), weight_path) From 687fe9ff6de6147599121cc433113f4e1817e269 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 17 Sep 2023 17:02:36 -0400 Subject: [PATCH 30/58] Comment db clean up temporarily. --- back-end/tests/test_app.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/back-end/tests/test_app.py b/back-end/tests/test_app.py index 66500318..86bf23e3 100644 --- a/back-end/tests/test_app.py +++ b/back-end/tests/test_app.py @@ -127,12 +127,12 @@ def _set_up(basedir): else: print("setup > no proposed dir, skip copy") - db_path = to_unix(osp.join(base_dir, "data.db")) - if osp.exists(db_path): - print("setup > delete " + db_path) - os.remove(db_path) - else: - print("setup > no db, skip delete") + # db_path = to_unix(osp.join(base_dir, "data.db")) + # if osp.exists(db_path): + # print("setup > delete " + db_path) + # os.remove(db_path) + # else: + # print("setup > no db, skip delete") visualize_images_dir = to_unix(osp.join(base_dir, "visualize_images")) if osp.exists(visualize_images_dir): @@ -195,12 +195,12 @@ def _clean_up(basedir): # os.rmdir(visualize_images_dir) # os.rename(visualize_images_dir_original, visualize_images_dir) - db_path = to_unix(osp.join(base_dir, "data.db")) - if osp.exists(db_path): - print("cleanup > delete " + db_path) - os.remove(db_path) - else: - print("cleanup > no db, skip delete") + # db_path = to_unix(osp.join(base_dir, "data.db")) + # if osp.exists(db_path): + # print("cleanup > delete " + db_path) + # os.remove(db_path) + # else: + # print("cleanup > no db, skip delete") # db_path = to_unix(osp.join(base_dir, 'data.db')) # db_path_original = to_unix(osp.join(base_dir, 'data_o.db')) From 8964492023761fa56ac4dccf9f8778e85f65082c Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Fri, 22 Sep 2023 11:02:31 -0400 Subject: [PATCH 31/58] Permit optional transmission of description and tags in metadata from frontend --- back-end/apis/model.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 7aabe682..93c06a95 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -1,8 +1,6 @@ -import os import json import string import uuid -import torch import io import contextlib from flask import request @@ -109,12 +107,10 @@ def UploadModel(): print("Requested to upload a new model") - # TODO: Discuss with the team about the naming of the model related files if the id is set to autoincrement # Generate a uuid for the model saving saving_id = str(uuid.uuid4()) code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.py') - weight_path = None metadata_4_save = {'name': None, 'description': None, @@ -194,8 +190,8 @@ def UploadModel(): # Update the metadata for saving metadata_4_save['name'] = name - metadata_4_save['description'] = metadata.get('description') - metadata_4_save['tags'] = metadata.get('tags') + metadata_4_save['description'] = metadata.get('description') if metadata.get('description') else None + metadata_4_save['tags'] = metadata.get('tags') if metadata.get('tags') else None metadata_4_save['create_time'] = datetime.now() metadata_4_save['code_path'] = code_path metadata_4_save['weight_path'] = weight_path From a240a6c82bf169516aa00b35893f9188e0fb1b14 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Fri, 22 Sep 2023 11:08:44 -0400 Subject: [PATCH 32/58] Update model metadata design: Introduce `nickname` and Rename `name` to `class_name` --- back-end/apis/model.py | 14 ++++++++------ back-end/database/model.py | 3 ++- back-end/utils/model_utils.py | 26 +++++++++++++------------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 93c06a95..fd74f34c 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -112,7 +112,8 @@ def UploadModel(): code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.py') - metadata_4_save = {'name': None, + metadata_4_save = {'class_name': None, + 'nickname': None, 'description': None, 'architecture': None, 'tags': None, @@ -127,8 +128,8 @@ def UploadModel(): 'last_eval_on_test_set': None } - # Get the model name - name = metadata.get('name') + # Get the model's class name + class_name = metadata.get('class_name') # If the model is custom(i.e. it has code definition) if 'code' in request.form: @@ -143,7 +144,7 @@ def UploadModel(): # Initialize the custom model try: - model = init_custom_model(code_path, name) + model = init_custom_model(code_path, class_name) except Exception as e: clear_model_temp_files(saving_id) return RResponse.abort(400, f"Failed to initialize the custom model. {e}") @@ -151,7 +152,7 @@ def UploadModel(): pretrained = bool(int(metadata.get('pretrained'))) num_classes = int(metadata.get('num_classes')) try: - model = init_predefined_model(name, pretrained, num_classes) + model = init_predefined_model(class_name, pretrained, num_classes) with open(code_path, 'w') as code_file: code_file.write(f"num_classes = {num_classes}") except Exception as e: @@ -189,7 +190,8 @@ def UploadModel(): return RResponse.abort(400, f"The model is invalid. {e}") # Update the metadata for saving - metadata_4_save['name'] = name + metadata_4_save['class_name'] = class_name + metadata_4_save['nickname'] = metadata.get('nickname') metadata_4_save['description'] = metadata.get('description') if metadata.get('description') else None metadata_4_save['tags'] = metadata.get('tags') if metadata.get('tags') else None metadata_4_save['create_time'] = datetime.now() diff --git a/back-end/database/model.py b/back-end/database/model.py index 2e9109aa..434fdfc4 100644 --- a/back-end/database/model.py +++ b/back-end/database/model.py @@ -21,7 +21,8 @@ class EvalResults(db.Model): class Models(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) - name = db.Column(db.String) + class_name = db.Column(db.String) + nickname = db.Column(db.String) description = db.Column(db.String) architecture = db.Column(db.String) tags = db.Column(db.String) diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index 5934de4b..8714c4f0 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -19,49 +19,49 @@ def __init__(self, model): self.device = RServer.get_model_wrapper().device -def init_predefined_model(name, pretrained, num_classes): +def init_predefined_model(class_name, pretrained, num_classes): """ Initialize the predefined model with the specified name """ # Check if the model name is valid - if name not in PREDEFINED_MODELS: - raise Exception(f"Predefined model name {name} not recognized.") + if class_name not in PREDEFINED_MODELS: + raise Exception(f"Predefined model name {class_name} not recognized.") # If the model is pretrained, it should have the same number of classes as the ImageNet model if pretrained and num_classes != IMAGENET_OUTPUT_SIZE: raise Exception(f"Pretrained model is supposed to have {IMAGENET_OUTPUT_SIZE} classes as output.") - if name == "ResNet18": + if class_name == "ResNet18": model = torchvision.models.resnet18(pretrained=pretrained, num_classes=num_classes) - elif name == "ResNet34": + elif class_name == "ResNet34": model = torchvision.models.resnet34(pretrained=pretrained, num_classes=num_classes) - elif name == "ResNet50": + elif class_name == "ResNet50": model = torchvision.models.resnet50(pretrained=pretrained, num_classes=num_classes) - elif name == "ResNet101": + elif class_name == "ResNet101": model = torchvision.models.resnet101(pretrained=pretrained, num_classes=num_classes) - elif name == "ResNet152": + elif class_name == "ResNet152": model = torchvision.models.resnet152(pretrained=pretrained, num_classes=num_classes) - elif name == "mobilenet-v2": + elif class_name == "mobilenet-v2": model = torchvision.models.mobilenet_v2(pretrained=pretrained, num_classes=num_classes) - elif name == "ResNet18-32x32": + elif class_name == "ResNet18-32x32": model = torchvision.models.ResNet(torchvision.models.resnet.BasicBlock, [2, 2, 2, 2], num_classes=num_classes) model.conv1 = torch.nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) model.maxpool = torch.nn.MaxPool2d(kernel_size=3, stride=1, padding=1) if pretrained: raise Exception("Pretrained ResNet18-32x32 is not available.") - elif name == "AlexNet": + elif class_name == "AlexNet": model = torchvision.models.alexnet(pretrained=pretrained, num_classes=num_classes) return model -def init_custom_model(code_path, name): +def init_custom_model(code_path, class_name): """ Initialize the custom model by importing the class with the specified name in the file specified by code_path """ try: spec = importlib.util.spec_from_file_location("model_def", code_path) model_def = importlib.util.module_from_spec(spec) spec.loader.exec_module(model_def) - model = getattr(model_def, name)() + model = getattr(model_def, class_name)() return model except Exception as e: print("Failed to initialize the model.") From 7684bdeb9da03a9c346568b94c0629257873d309 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Fri, 22 Sep 2023 13:42:00 -0400 Subject: [PATCH 33/58] Extract 'tags' column from 'Models' table into a new table, and maintain the relationship --- back-end/database/model.py | 12 +++++++++++- back-end/objects/RModelWrapper.py | 13 ++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/back-end/database/model.py b/back-end/database/model.py index 434fdfc4..8a6a3781 100644 --- a/back-end/database/model.py +++ b/back-end/database/model.py @@ -17,6 +17,11 @@ class EvalResults(db.Model): ), db.Column("influ_path", db.Integer), ) +model_tag_rel = db.Table( + 'model_tag_rel', + db.Column('model_id', db.Integer, db.ForeignKey('models.id'), primary_key=True), + db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True) +) class Models(db.Model): @@ -25,7 +30,7 @@ class Models(db.Model): nickname = db.Column(db.String) description = db.Column(db.String) architecture = db.Column(db.String) - tags = db.Column(db.String) + tags = db.relationship('Tags', secondary=model_tag_rel, backref='models') create_time = db.Column(db.DateTime) weight_path = db.Column(db.String) code_path = db.Column(db.String) @@ -44,6 +49,11 @@ class Models(db.Model): ) +class Tags(db.Model): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String, unique=True) + + class PairedSetImage(db.Model): path = db.Column(db.String, primary_key=True) train_path = db.Column(db.String) diff --git a/back-end/objects/RModelWrapper.py b/back-end/objects/RModelWrapper.py index b7b0dab2..703ce160 100644 --- a/back-end/objects/RModelWrapper.py +++ b/back-end/objects/RModelWrapper.py @@ -123,7 +123,18 @@ def create_model(self, fields: dict): # TODO: Need to validate fields, dump model definition to a file, # etc. Either do these here or somewhere else - model = Models(**fields) + tags = fields.pop('tags', []) + + tag_objs = [] + if tags: + for tag_name in tags: + tag = self.db_conn.session.query(Tags).filter_by(name=tag_name).first() + if tag is None: + tag = Tags(name=tag_name) + self.db_conn.session.add(tag) + tag_objs.append(tag) + + model = Models(**fields, tags=tag_objs) self.db_conn.session.add(model) self.db_conn.session.commit() From 68d16cdb98f3d3e8d67cbef07fc5dbf97c61ae87 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Fri, 22 Sep 2023 14:14:50 -0400 Subject: [PATCH 34/58] Enhance `UploadModel` docstring documentation --- back-end/apis/model.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index fd74f34c..4e63ac55 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -46,7 +46,7 @@ def DeleteModel(): @model_api.route("/model", methods=["POST"]) def UploadModel(): """ - Should also accept (optionally) a model weight file as argument + Upload a new model to the server --- tags: - model @@ -71,6 +71,35 @@ def UploadModel(): required: false # The weight file is optional, so set 'required' to false type: "file" # The type of data for the weight file (file upload) + definitions: + Metadata: + type: "object" + properties: + class_name: + type: "string" + description: "The name of the model class." + required: true + nickname: + type: "string" + description: "A nickname for the model." + required: true + description: + type: "string" + description: "A description of the model (optional)." + tags: + type: "array" + items: + type: "string" + description: "A list of tags associated with the model (optional)." + pretrained: + type: "string" + description: | + Indicates if a predefined model is being used. + "1" represents pretrained, "0" otherwise (required only if users choose to use a predefined model). + num_classes: + type: "integer" + description: "The number of classes (required only if users choose to use a predefined model)." + responses: 200: description: Model uploaded successfully From f3f04a895fe607c2f33750d3a19423361a1e25cb Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sat, 30 Sep 2023 22:54:56 -0400 Subject: [PATCH 35/58] Close DB connection explicitly for .db file removal; Set fixture scope to 'function' for fresh instances per test. --- back-end/tests/test_app.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/back-end/tests/test_app.py b/back-end/tests/test_app.py index 86bf23e3..e501788e 100644 --- a/back-end/tests/test_app.py +++ b/back-end/tests/test_app.py @@ -8,12 +8,13 @@ from objects.RServer import RServer from server import start_flask_app, new_server_object from utils.path_utils import to_unix +from database.db_init import db PARAM_NAME_IMAGE_PATH = "image_url" flask_app = None -@pytest.fixture() +@pytest.fixture(scope='function') def app(request): global flask_app basedir = request.config.getoption("basedir") @@ -22,7 +23,7 @@ def app(request): if flask_app is None: flask_app, _ = start_flask_app() - server = new_server_object(basedir) + new_server_object(basedir) server = RServer.get_server() flask_app = server.get_flask_app() @@ -30,6 +31,10 @@ def app(request): yield flask_app flask_app.config["TESTING"] = False + with flask_app.app_context(): + db.session.remove() + db.engine.dispose() + except Exception as e: _clean_up(basedir) raise e @@ -39,7 +44,7 @@ def app(request): time.sleep(0.1) -@pytest.fixture() +@pytest.fixture(scope='function') def client(app): yield app.test_client() @@ -127,12 +132,12 @@ def _set_up(basedir): else: print("setup > no proposed dir, skip copy") - # db_path = to_unix(osp.join(base_dir, "data.db")) - # if osp.exists(db_path): - # print("setup > delete " + db_path) - # os.remove(db_path) - # else: - # print("setup > no db, skip delete") + db_path = to_unix(osp.join(base_dir, "data.db")) + if osp.exists(db_path): + print("setup > delete " + db_path) + os.remove(db_path) + else: + print("setup > no db, skip delete") visualize_images_dir = to_unix(osp.join(base_dir, "visualize_images")) if osp.exists(visualize_images_dir): @@ -195,12 +200,12 @@ def _clean_up(basedir): # os.rmdir(visualize_images_dir) # os.rename(visualize_images_dir_original, visualize_images_dir) - # db_path = to_unix(osp.join(base_dir, "data.db")) - # if osp.exists(db_path): - # print("cleanup > delete " + db_path) - # os.remove(db_path) - # else: - # print("cleanup > no db, skip delete") + db_path = to_unix(osp.join(base_dir, "data.db")) + if osp.exists(db_path): + print("cleanup > delete " + db_path) + os.remove(db_path) + else: + print("cleanup > no db, skip delete") # db_path = to_unix(osp.join(base_dir, 'data.db')) # db_path_original = to_unix(osp.join(base_dir, 'data_o.db')) @@ -265,4 +270,4 @@ def _clean_up(basedir): # # # Compare each item in them # for key_item_1, key_item_2 in zip(weightLoaded.items(), weightInMem.items()): -# assert torch.equal(key_item_1[1], key_item_2[1]) +# assert torch.equal(key_item_1[1], key_item_2[1]) \ No newline at end of file From e8429f2a2a6f30b5c47b501dc3206b2964d0b97c Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 12:06:42 -0400 Subject: [PATCH 36/58] Change the base directory in pytest for debugging --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2fbe7b87..edf13b2a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -45,7 +45,7 @@ jobs: pip install pytest pip install -r requirements.txt pip install protobuf==3.20.* - python -m pytest --basedir ./Robustar2 -W ignore::UserWarning -W ignore::DeprecationWarning + python -m pytest --basedir ../Robustar2 -W ignore::UserWarning -W ignore::DeprecationWarning - run: name: End-to-end tests From e3a08a1d09e1311c3dc950e03cc594173b5982d5 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 12:13:05 -0400 Subject: [PATCH 37/58] Debugging --- .circleci/config.yml | 16 ++++++++-------- back-end/server.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index edf13b2a..bc389868 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,13 +18,13 @@ jobs: echo '^^^^^^^^^^^^^ Files cloned from repo' echo 'Current Branch: $(git branch --show-current)' echo 'Commit: $(git rev-parse HEAD)' - - run: - name: Download frontend dependencies and build - working_directory: ./front-end - command: | - echo "Files in directory: $(ls)" - npm install - npm run build + # - run: + # name: Download frontend dependencies and build + # working_directory: ./front-end + # command: | + # echo "Files in directory: $(ls)" + # npm install + # npm run build - run: name: Download dataset @@ -45,7 +45,7 @@ jobs: pip install pytest pip install -r requirements.txt pip install protobuf==3.20.* - python -m pytest --basedir ../Robustar2 -W ignore::UserWarning -W ignore::DeprecationWarning + python -m pytest --basedir ./Robustar2 -W ignore::UserWarning -W ignore::DeprecationWarning - run: name: End-to-end tests diff --git a/back-end/server.py b/back-end/server.py index 3d982f4a..59a453db 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -104,7 +104,7 @@ def new_server_object(base_dir): dataset_dir = to_unix(osp.join(base_dir, "dataset")) ckpt_dir = to_unix(osp.join(base_dir, "checkpoints")) db_path = to_unix(osp.join(base_dir, "data.db")) - + print(f'bdir: {base_dir}, ddir: {dataset_dir}, ckpt: {ckpt_dir}, db: {db_path}') with open(osp.join(base_dir, "configs.json")) as jsonfile: configs = json.load(jsonfile) From eab8f7aef112381cdc563a7823c41284c96d58d4 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 12:39:37 -0400 Subject: [PATCH 38/58] Debugging --- back-end/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/back-end/server.py b/back-end/server.py index 59a453db..45b183fb 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -104,7 +104,7 @@ def new_server_object(base_dir): dataset_dir = to_unix(osp.join(base_dir, "dataset")) ckpt_dir = to_unix(osp.join(base_dir, "checkpoints")) db_path = to_unix(osp.join(base_dir, "data.db")) - print(f'bdir: {base_dir}, ddir: {dataset_dir}, ckpt: {ckpt_dir}, db: {db_path}') + with open(osp.join(base_dir, "configs.json")) as jsonfile: configs = json.load(jsonfile) @@ -142,6 +142,8 @@ def new_server_object(base_dir): """ SETUP DATA MANAGER """ # Setup database db_conn_str = f"sqlite:///{db_path}" + print("Database path: ", db_path) + print(f"Database connection string: {db_conn_str}") app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str from database.db_init import db, init_db From bbbd56c17df9555ee79af3b923904e8227c07b3c Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 12:56:58 -0400 Subject: [PATCH 39/58] Debugging --- back-end/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/back-end/server.py b/back-end/server.py index 45b183fb..220a274b 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -147,6 +147,7 @@ def new_server_object(base_dir): app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str from database.db_init import db, init_db + print(f'app.config["SQLALCHEMY_DATABASE_URI"]: {app.config["SQLALCHEMY_DATABASE_URI"]}') db.init_app(app) init_db(app) # Setup data manager From 552b6203ef0adb90a4b01091701ba7f5a1e3eb53 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 13:00:06 -0400 Subject: [PATCH 40/58] Debugging --- back-end/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/back-end/server.py b/back-end/server.py index 220a274b..72d67787 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -147,6 +147,7 @@ def new_server_object(base_dir): app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str from database.db_init import db, init_db + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////home/circleci/project/back-end/Robustar2/data.db" print(f'app.config["SQLALCHEMY_DATABASE_URI"]: {app.config["SQLALCHEMY_DATABASE_URI"]}') db.init_app(app) init_db(app) From 3cd7281760e1465f6f0b68150464bc57c7262720 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 13:15:08 -0400 Subject: [PATCH 41/58] Debugging --- back-end/database/db_init.py | 7 +++++-- back-end/server.py | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/back-end/database/db_init.py b/back-end/database/db_init.py index 739cc5ee..a49785e2 100644 --- a/back-end/database/db_init.py +++ b/back-end/database/db_init.py @@ -1,9 +1,12 @@ from flask_sqlalchemy import SQLAlchemy -db = SQLAlchemy() +def create_sqlalchemy_db(app): + with app.app_context(): + db = SQLAlchemy(app) + return db -def init_db(app): +def init_db(app, db): print("Initializing database ... ") with app.app_context(): db.create_all() diff --git a/back-end/server.py b/back-end/server.py index 72d67787..fbdef699 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -145,12 +145,13 @@ def new_server_object(base_dir): print("Database path: ", db_path) print(f"Database connection string: {db_conn_str}") app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str - from database.db_init import db, init_db + from database.db_init import create_sqlalchemy_db, init_db app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////home/circleci/project/back-end/Robustar2/data.db" - print(f'app.config["SQLALCHEMY_DATABASE_URI"]: {app.config["SQLALCHEMY_DATABASE_URI"]}') + # print(f'app.config["SQLALCHEMY_DATABASE_URI"]: {app.config["SQLALCHEMY_DATABASE_URI"]}') + db = create_sqlalchemy_db(app) db.init_app(app) - init_db(app) + init_db(app, db) # Setup data manager data_manager = RDataManager( base_dir, From 12de854b5293197870da6024626719bfc4768b07 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 13:16:31 -0400 Subject: [PATCH 42/58] Debugging --- back-end/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/back-end/server.py b/back-end/server.py index fbdef699..55775329 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -147,7 +147,7 @@ def new_server_object(base_dir): app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str from database.db_init import create_sqlalchemy_db, init_db - app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////home/circleci/project/back-end/Robustar2/data.db" + # app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////home/circleci/project/back-end/Robustar2/data.db" # print(f'app.config["SQLALCHEMY_DATABASE_URI"]: {app.config["SQLALCHEMY_DATABASE_URI"]}') db = create_sqlalchemy_db(app) db.init_app(app) From 9189ca3cdbbf2df0db9df88189c1adae655ce532 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 13:24:00 -0400 Subject: [PATCH 43/58] Restore for a full test --- .circleci/config.yml | 14 +++++++------- back-end/server.py | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index bc389868..2fbe7b87 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,13 +18,13 @@ jobs: echo '^^^^^^^^^^^^^ Files cloned from repo' echo 'Current Branch: $(git branch --show-current)' echo 'Commit: $(git rev-parse HEAD)' - # - run: - # name: Download frontend dependencies and build - # working_directory: ./front-end - # command: | - # echo "Files in directory: $(ls)" - # npm install - # npm run build + - run: + name: Download frontend dependencies and build + working_directory: ./front-end + command: | + echo "Files in directory: $(ls)" + npm install + npm run build - run: name: Download dataset diff --git a/back-end/server.py b/back-end/server.py index 72d67787..9eacf595 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -147,6 +147,7 @@ def new_server_object(base_dir): app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str from database.db_init import db, init_db + print(f'app.config["SQLALCHEMY_DATABASE_URI"] before hardcoding: {app.config["SQLALCHEMY_DATABASE_URI"]}') app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////home/circleci/project/back-end/Robustar2/data.db" print(f'app.config["SQLALCHEMY_DATABASE_URI"]: {app.config["SQLALCHEMY_DATABASE_URI"]}') db.init_app(app) From 0bd03393840669855c00329598d117647c3a69c7 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 14:41:11 -0400 Subject: [PATCH 44/58] Capture the 'stdout' and 'stderr' in pytest for debugging --- .circleci/config.yml | 2 +- back-end/server.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2fbe7b87..391d390d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -45,7 +45,7 @@ jobs: pip install pytest pip install -r requirements.txt pip install protobuf==3.20.* - python -m pytest --basedir ./Robustar2 -W ignore::UserWarning -W ignore::DeprecationWarning + python -m pytest --capture=sys --show-capture=all --basedir ./Robustar2 -W ignore::UserWarning -W ignore::DeprecationWarning - run: name: End-to-end tests diff --git a/back-end/server.py b/back-end/server.py index 9eacf595..ebe54a67 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -142,8 +142,6 @@ def new_server_object(base_dir): """ SETUP DATA MANAGER """ # Setup database db_conn_str = f"sqlite:///{db_path}" - print("Database path: ", db_path) - print(f"Database connection string: {db_conn_str}") app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str from database.db_init import db, init_db From 8d31540039b7b9d9aa6d7aa26658d7fe4296dd62 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 14:46:15 -0400 Subject: [PATCH 45/58] Debugging --- .circleci/config.yml | 14 +++++++------- back-end/server.py | 2 ++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 391d390d..dac83100 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,13 +18,13 @@ jobs: echo '^^^^^^^^^^^^^ Files cloned from repo' echo 'Current Branch: $(git branch --show-current)' echo 'Commit: $(git rev-parse HEAD)' - - run: - name: Download frontend dependencies and build - working_directory: ./front-end - command: | - echo "Files in directory: $(ls)" - npm install - npm run build + # - run: + # name: Download frontend dependencies and build + # working_directory: ./front-end + # command: | + # echo "Files in directory: $(ls)" + # npm install + # npm run build - run: name: Download dataset diff --git a/back-end/server.py b/back-end/server.py index ebe54a67..0bb28449 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -146,8 +146,10 @@ def new_server_object(base_dir): from database.db_init import db, init_db print(f'app.config["SQLALCHEMY_DATABASE_URI"] before hardcoding: {app.config["SQLALCHEMY_DATABASE_URI"]}') + original_uri = app.config["SQLALCHEMY_DATABASE_URI"] app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////home/circleci/project/back-end/Robustar2/data.db" print(f'app.config["SQLALCHEMY_DATABASE_URI"]: {app.config["SQLALCHEMY_DATABASE_URI"]}') + app.config["SQLALCHEMY_DATABASE_URI"] = original_uri db.init_app(app) init_db(app) # Setup data manager From d2c9f2ed2fc908851f4ee93443ae3b44b43856a5 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 14:57:43 -0400 Subject: [PATCH 46/58] Debugging --- back-end/server.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/back-end/server.py b/back-end/server.py index 0bb28449..07336cf2 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -141,15 +141,15 @@ def new_server_object(base_dir): """ SETUP DATA MANAGER """ # Setup database - db_conn_str = f"sqlite:///{db_path}" + db_conn_str = f"sqlite:///{to_absolute(db_path)}" app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str from database.db_init import db, init_db print(f'app.config["SQLALCHEMY_DATABASE_URI"] before hardcoding: {app.config["SQLALCHEMY_DATABASE_URI"]}') - original_uri = app.config["SQLALCHEMY_DATABASE_URI"] - app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////home/circleci/project/back-end/Robustar2/data.db" - print(f'app.config["SQLALCHEMY_DATABASE_URI"]: {app.config["SQLALCHEMY_DATABASE_URI"]}') - app.config["SQLALCHEMY_DATABASE_URI"] = original_uri + # original_uri = app.config["SQLALCHEMY_DATABASE_URI"] + # app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////home/circleci/project/back-end/Robustar2/data.db" + # print(f'app.config["SQLALCHEMY_DATABASE_URI"]: {app.config["SQLALCHEMY_DATABASE_URI"]}') + # app.config["SQLALCHEMY_DATABASE_URI"] = original_uri # Intended to fail the test db.init_app(app) init_db(app) # Setup data manager From 7fff5514a7cfec41cb4c9fae164ef5d78b6383ea Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 15:03:44 -0400 Subject: [PATCH 47/58] Debugging --- back-end/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/back-end/server.py b/back-end/server.py index 07336cf2..22c91e11 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -141,7 +141,7 @@ def new_server_object(base_dir): """ SETUP DATA MANAGER """ # Setup database - db_conn_str = f"sqlite:///{to_absolute(db_path)}" + db_conn_str = f"sqlite:///{to_absolute(os.getcwd(), db_path)}" app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str from database.db_init import db, init_db From d21da8fdfb489a21edb8fdc236fb669f29b5d8ea Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 15:08:28 -0400 Subject: [PATCH 48/58] Solve the problem by specifying the db file address as absolute path --- back-end/server.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/back-end/server.py b/back-end/server.py index 22c91e11..14e74435 100644 --- a/back-end/server.py +++ b/back-end/server.py @@ -145,11 +145,6 @@ def new_server_object(base_dir): app.config["SQLALCHEMY_DATABASE_URI"] = db_conn_str from database.db_init import db, init_db - print(f'app.config["SQLALCHEMY_DATABASE_URI"] before hardcoding: {app.config["SQLALCHEMY_DATABASE_URI"]}') - # original_uri = app.config["SQLALCHEMY_DATABASE_URI"] - # app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////home/circleci/project/back-end/Robustar2/data.db" - # print(f'app.config["SQLALCHEMY_DATABASE_URI"]: {app.config["SQLALCHEMY_DATABASE_URI"]}') - # app.config["SQLALCHEMY_DATABASE_URI"] = original_uri # Intended to fail the test db.init_app(app) init_db(app) # Setup data manager From 47a9d4b94396db18e1b4884580f113fe73f2666d Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 1 Oct 2023 15:12:09 -0400 Subject: [PATCH 49/58] Restore the config of CircleCI test --- .circleci/config.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index dac83100..2fbe7b87 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,13 +18,13 @@ jobs: echo '^^^^^^^^^^^^^ Files cloned from repo' echo 'Current Branch: $(git branch --show-current)' echo 'Commit: $(git rev-parse HEAD)' - # - run: - # name: Download frontend dependencies and build - # working_directory: ./front-end - # command: | - # echo "Files in directory: $(ls)" - # npm install - # npm run build + - run: + name: Download frontend dependencies and build + working_directory: ./front-end + command: | + echo "Files in directory: $(ls)" + npm install + npm run build - run: name: Download dataset @@ -45,7 +45,7 @@ jobs: pip install pytest pip install -r requirements.txt pip install protobuf==3.20.* - python -m pytest --capture=sys --show-capture=all --basedir ./Robustar2 -W ignore::UserWarning -W ignore::DeprecationWarning + python -m pytest --basedir ./Robustar2 -W ignore::UserWarning -W ignore::DeprecationWarning - run: name: End-to-end tests From 6b9b02976a2ff8af13a156626d698af498b9fd8a Mon Sep 17 00:00:00 2001 From: Chonghan Date: Sun, 1 Oct 2023 19:49:48 -0700 Subject: [PATCH 50/58] fix: provide app context to all thread creations --- back-end/apis/predict.py | 15 ++++++--------- back-end/utils/edit_utils.py | 4 +++- back-end/utils/train.py | 13 +++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/back-end/apis/predict.py b/back-end/apis/predict.py index ee7c2e36..cebec25f 100644 --- a/back-end/apis/predict.py +++ b/back-end/apis/predict.py @@ -129,15 +129,11 @@ def predict(split): model_wrapper.release_model() if len(output) != 4: - RResponse.abort( - 500, "[Unexpected] Invalid number of predict visualize figures" - ) + RResponse.abort(500, "[Unexpected] Invalid number of predict visualize figures") predict_fig_routes = [] for i, fig in enumerate(output): - predict_fig_route = "{}/{}_{}.png".format( - visualize_root, image_name, str(i) - ) + predict_fig_route = "{}/{}_{}.png".format(visualize_root, image_name, str(i)) fig.savefig(predict_fig_route) predict_fig_routes.append(predict_fig_route) @@ -222,7 +218,7 @@ def calculate_influence(): recursion_depth: 9000, scale: 5000, batch_size: 16, - num_workers: 5, + num_workers: 5, } responses: 200: @@ -256,6 +252,7 @@ def calculate_influence(): except Exception as e: model_wrapper.release_model() RResponse.abort(500, f"Failed to create influence calculation thread. ({e})") - - calc_influence_thread.start() + + with RServer.get_server().get_flask_app().app_context(): + calc_influence_thread.start() return RResponse.ok({}, "Influence calculation started!") diff --git a/back-end/utils/edit_utils.py b/back-end/utils/edit_utils.py index 19dff88d..b64e68e3 100644 --- a/back-end/utils/edit_utils.py +++ b/back-end/utils/edit_utils.py @@ -144,4 +144,6 @@ def auto_annotate_thread(split, start, end): auto_annotate_thread = threading.Thread( target=auto_annotate_thread, args=(split, start, end) ) - auto_annotate_thread.start() + + with RServer.get_server().get_flask_app().app_context(): + auto_annotate_thread.start() diff --git a/back-end/utils/train.py b/back-end/utils/train.py index 47f27dab..f5473874 100644 --- a/back-end/utils/train.py +++ b/back-end/utils/train.py @@ -15,11 +15,12 @@ def __init__(self, trainer, configs): def run(self): try: - self.trainer.start_train( - call_back=lambda status_dict: self.update_info(status_dict), - epochs=int(self.configs["epoch"]), - auto_save=self.configs["auto_save_model"] == "yes", - ) + with RServer.get_server().get_flask_app().app_context(): + self.trainer.start_train( + call_back=lambda status_dict: self.update_info(status_dict), + epochs=int(self.configs["epoch"]), + auto_save=self.configs["auto_save_model"] == "yes", + ) except Exception as e: raise e finally: @@ -115,7 +116,7 @@ def start_train(configs): try: train_set, test_set, model, trainer = setup_training(configs) # Set up tensorboard log directory - tb_dir = "runs" + tb_dir = "runs" date = datetime.now().strftime("%Y_%m_%d_%I_%M_%S_%p") if not os.path.exists(tb_dir): os.mkdir(tb_dir) From 92e0ffa60f80e9714693aa5121747e0ee0a48a44 Mon Sep 17 00:00:00 2001 From: Chonghan Date: Tue, 10 Oct 2023 00:35:50 -0700 Subject: [PATCH 51/58] fix: correctly getting context for child threads --- back-end/apis/predict.py | 3 +- back-end/utils/edit_utils.py | 43 +++++++++++++------------- back-end/utils/predict.py | 60 +++++++++++++++++++++++------------- 3 files changed, 61 insertions(+), 45 deletions(-) diff --git a/back-end/apis/predict.py b/back-end/apis/predict.py index cebec25f..5c784b6a 100644 --- a/back-end/apis/predict.py +++ b/back-end/apis/predict.py @@ -253,6 +253,5 @@ def calculate_influence(): model_wrapper.release_model() RResponse.abort(500, f"Failed to create influence calculation thread. ({e})") - with RServer.get_server().get_flask_app().app_context(): - calc_influence_thread.start() + calc_influence_thread.start() return RResponse.ok({}, "Influence calculation started!") diff --git a/back-end/utils/edit_utils.py b/back-end/utils/edit_utils.py index b64e68e3..c65336c5 100644 --- a/back-end/utils/edit_utils.py +++ b/back-end/utils/edit_utils.py @@ -121,29 +121,30 @@ def start_auto_annotate(split, start: int, end: int): return def auto_annotate_thread(split, start, end): - task = RTask(TaskType.AutoAnnotate, end - start) - starttime = time.time() - - for train_path in data_manager.trainset.get_image_list(start, end): - # Propose edit for this image - proposed_image_path, pil_image = propose_edit(split, train_path, True) - - # Save the image to paired data folder - data_manager.pairedset.save_annotated_image( - train_path, data_manager.trainset, pil_image - ) - - task_update_res = task.update() - if not task_update_res: - endtime = time.time() - print("Time consumption:", endtime - starttime) - print("Auto annotate stopped!") - return - # task.exit() + + with RServer.get_server().get_flask_app().app_context(): + task = RTask(TaskType.AutoAnnotate, end - start) + starttime = time.time() + + for train_path in data_manager.trainset.get_image_list(start, end): + # Propose edit for this image + proposed_image_path, pil_image = propose_edit(split, train_path, True) + + # Save the image to paired data folder + data_manager.pairedset.save_annotated_image( + train_path, data_manager.trainset, pil_image + ) + + task_update_res = task.update() + if not task_update_res: + endtime = time.time() + print("Time consumption:", endtime - starttime) + print("Auto annotate stopped!") + return + # task.exit() auto_annotate_thread = threading.Thread( target=auto_annotate_thread, args=(split, start, end) ) - with RServer.get_server().get_flask_app().app_context(): - auto_annotate_thread.start() + auto_annotate_thread.start() diff --git a/back-end/utils/predict.py b/back-end/utils/predict.py index f4348db4..d47a0de9 100644 --- a/back-end/utils/predict.py +++ b/back-end/utils/predict.py @@ -59,9 +59,7 @@ def get_image_prediction( def calculate_influence( - model_wrapper: RModelWrapper, - data_manager: RDataManager, - in_config + model_wrapper: RModelWrapper, data_manager: RDataManager, in_config ): """ Calculate the influence function for the model. @@ -77,10 +75,20 @@ def calculate_influence( config = ptif.get_default_config() config.update(in_config) - batch_size = config['batch_size'] - num_workers = config['num_workers'] - testloader = torch.utils.data.DataLoader(data_manager.testset, batch_size=batch_size, shuffle=False, num_workers=num_workers) - trainloader = torch.utils.data.DataLoader(data_manager.trainset, batch_size=batch_size, shuffle=False, num_workers=num_workers) + batch_size = config["batch_size"] + num_workers = config["num_workers"] + testloader = torch.utils.data.DataLoader( + data_manager.testset, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + ) + trainloader = torch.utils.data.DataLoader( + data_manager.trainset, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + ) end_idx = config["test_end_index"] if end_idx == -1: @@ -92,7 +100,9 @@ def calculate_influence( config["test_sample_num"] = end_idx - config["test_start_index"] config["outdir"] = data_manager.influence_log_path - print(f"Starting influence calculation with the following configuration: \n {config}") + print( + f"Starting influence calculation with the following configuration: \n {config}" + ) # max_influence_dicts is the dictionary containing the four most influential training images for each testing image # e.g. { @@ -109,7 +119,6 @@ def calculate_influence( # TODO: change argument from testloader to misclassified test loader # as we are only interested in the influence for misclassified samples - # TODO: Now max_influence_dicts will be empty if user stops the influence calculation # (i.e., all previous results are thrown away because we only return the full result when # influence is calculated for all specified images). Maybe we should instead return influence @@ -136,11 +145,12 @@ def calculate_influence( for j in range(4): train_id = int(train_ids[j]) - train_img_path = data_manager.trainset.get_image_list(train_id, train_id + 1)[0] + train_img_path = data_manager.trainset.get_image_list( + train_id, train_id + 1 + )[0] train_img_paths.append(train_img_path) influences[test_img_path] = train_img_paths - data_manager.get_influence_dict().update(influences) if influences: @@ -148,17 +158,23 @@ def calculate_influence( pickle.dump(data_manager.get_influence_dict(), influence_file) print("Influence calculation done.") + def get_calc_influence_thread(configs): # Parse the following fields from string to integer - for key in ["test_start_index", "test_end_index", "recursion_depth", "r_averaging", "scale"]: - configs[key] = int(configs[key]) + for key in [ + "test_start_index", + "test_end_index", + "recursion_depth", + "r_averaging", + "scale", + ]: + configs[key] = int(configs[key]) return CalcInfluenceThread( - RServer.get_model_wrapper(), - RServer.get_data_manager(), - configs + RServer.get_model_wrapper(), RServer.get_data_manager(), configs ) + class CalcInfluenceThread(threading.Thread): def __init__( self, @@ -173,13 +189,13 @@ def __init__( def run(self): try: - calculate_influence( - self.model_wrapper, - self.dataManager, - self.config, - ) + with RServer.get_server().get_flask_app().app_context(): + calculate_influence( + self.model_wrapper, + self.dataManager, + self.config, + ) except Exception as e: raise e finally: RServer.get_model_wrapper().release_model() - From 9061a128e04d09868560c463ac186fd6471a09af Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 15 Oct 2023 10:50:13 -0400 Subject: [PATCH 52/58] Resolve code review feedback: - Improve robustness by checking for 'code' and 'pretrained' in request - Remove 'back-end/database/db_ops.py' file (double-checked for necessity) Closes: #192 --- back-end/apis/model.py | 4 +- back-end/database/db_ops.py | 125 ------------------------------------ 2 files changed, 3 insertions(+), 126 deletions(-) delete mode 100644 back-end/database/db_ops.py diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 4e63ac55..e486cebb 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -177,7 +177,7 @@ def UploadModel(): except Exception as e: clear_model_temp_files(saving_id) return RResponse.abort(400, f"Failed to initialize the custom model. {e}") - else: # If the model is predefined + elif 'pretrained' in metadata: # If the model is predefined pretrained = bool(int(metadata.get('pretrained'))) num_classes = int(metadata.get('num_classes')) try: @@ -186,6 +186,8 @@ def UploadModel(): code_file.write(f"num_classes = {num_classes}") except Exception as e: return RResponse.abort(400, f"Failed to initialize the predefined model. {e}") + else: + return RResponse.abort(400, "The model is neither custom nor predefined.") # Get the weight file and save it to a temporary location if it exists if 'weight_file' in request.files: diff --git a/back-end/database/db_ops.py b/back-end/database/db_ops.py deleted file mode 100644 index d6633044..00000000 --- a/back-end/database/db_ops.py +++ /dev/null @@ -1,125 +0,0 @@ -from typing import List, Tuple -from sqlite3.dbapi2 import Cursor, Connection -from flask_sqlalchemy import SQLAlchemy - -# Note: commit should be done by the caller after flushing in-memory buffer -# to ensure consistency! - - -def db_insert(db_conn: Connection, table_name: str, keys: Tuple[str], values: Tuple): - assert len(keys) == len(values), "key and value array length not equal" - - db_cursor = db_conn.cursor() - template = "INSERT INTO {} ({}) values ({})".format( - table_name, ",".join(keys), ",".join(["?" for _ in values]) - ) - - db_cursor.execute(template, values) - - -def db_insert_many( - db_conn: Connection, table_name: str, keys: Tuple[str], values: List[Tuple] -): - if len(values) == 0: - return - assert len(keys) == len( - values[0] - ), "key and value array does not contain the same number of attributes" - - db_cursor = db_conn.cursor() - template = "INSERT INTO {} ({}) values ({})".format( - table_name, ",".join(keys), ",".join(["?" for _ in keys]) - ) - - db_cursor.executemany(template, values) - - -def db_select_all(db_conn: Connection, table_name: str) -> List[Tuple]: - db_cursor = db_conn.cursor() - template = "SELECT * from {}".format( - table_name, - ) - - db_cursor.execute(template) - return db_cursor.fetchall() - - -def db_select_by_path(db_conn: Connection, table_name: str, path: str) -> List[Tuple]: - - db_cursor = db_conn.cursor() - template = "SELECT * from {} WHERE path=?".format( - table_name, - ) - - db_cursor.execute(template, (path,)) - return db_cursor.fetchall() - - -def db_update_by_path( - db_conn: Connection, table_name: str, path: str, keys: Tuple[str], values: Tuple -): - assert len(keys) == len(values), "key and value array length not equal" - db_cursor = db_conn.cursor() - - template = "UPDATE {} SET {} WHERE path=?".format( - table_name, ",".join(["{} = ?".format(key) for key in keys]) - ) - - db_cursor.execute(template, values + (path,)) - - -def db_update_many_by_paths( - db_conn: Connection, - table_name: str, - paths: List[str], - keys: Tuple[str], - values_list: List[Tuple], -): - db_cursor = db_conn.cursor() - - template = "UPDATE {} SET {} WHERE path=?".format( - table_name, ",".join(["{} = ?".format(key) for key in keys]) - ) - - db_cursor.executemany( - template, [values + (path,) for (values, path) in zip(values_list, paths)] - ) - - -def db_delete_by_path(db_conn: Connection, table_name: str, path: str): - db_cursor = db_conn.cursor() - - template = "DELETE FROM {} WHERE path=?".format(table_name) - - db_cursor.execute(template, (path,)) - - -def db_delete_all(db_conn: Connection, table_name: str): - db_cursor = db_conn.cursor() - - template = "DELETE FROM {}".format(table_name) - - db_cursor.execute(template) - - -def db_count_all(db_conn: Connection, table_name: str): - db_cursor = db_conn.cursor() - - template = "SELECT COUNT (*) FROM {}".format(table_name) - - db_cursor.execute(template) - return db_cursor.fetchone()[0] - - -def db_get_cls_result(db_conn: Connection, table_name: str, correct: bool): - db_cursor = db_conn.cursor() - - if correct: - classified = 1 - else: - classified = 2 - - template = "SELECT * from {} WHERE classified=?".format(table_name) - - db_cursor.execute(template, classified) - return db_cursor.fetchall() From 2b9986b62b60601fb4e115c60b4a9350d897f21a Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 29 Oct 2023 16:28:14 -0400 Subject: [PATCH 53/58] Add `predefined` to the model's metadata --- back-end/apis/model.py | 131 +++++++++++++++++++++---------------- back-end/database/model.py | 9 +-- 2 files changed, 80 insertions(+), 60 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index e486cebb..799d2d76 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -12,9 +12,10 @@ model_api = Blueprint("model_api", __name__) + @model_api.route("/model/current", methods=["GET"]) def GetCurrModel(): - """ Get the model that is currently active """ + """Get the model that is currently active""" """ return data { id: string, @@ -27,13 +28,13 @@ def GetCurrModel(): @model_api.route("/model/current/", methods=["POST"]) def SetCurrModel(model_id: string): - """ return 200 on success """ + """return 200 on success""" pass @model_api.route("/model/", methods=["DELETE"]) def DeleteModel(): - """ return data + """return data { id: string, name: string, @@ -83,6 +84,11 @@ def UploadModel(): type: "string" description: "A nickname for the model." required: true + predefined: + type: "string" + description: | + Indicates if a predefined model is being used. + "1" represents predefined, "0" otherwise. description: type: "string" description: "A description of the model (optional)." @@ -94,11 +100,11 @@ def UploadModel(): pretrained: type: "string" description: | - Indicates if a predefined model is being used. - "1" represents pretrained, "0" otherwise (required only if users choose to use a predefined model). + Indicates if a predefined model uses pretrained weights. + "1" represents pretrained, "0" otherwise (required if predefined). num_classes: type: "integer" - description: "The number of classes (required only if users choose to use a predefined model)." + description: "The number of classes for the predefined model (required if predefined)." responses: 200: @@ -127,10 +133,10 @@ def UploadModel(): """ # Get the model's metadata - metadata = json.loads(request.form.get('metadata')) + metadata = json.loads(request.form.get("metadata")) # Check if the folder for saving models exists, if not, create it - models_dir = os.path.join(RServer.get_server().base_dir, 'generated', 'models') + models_dir = os.path.join(RServer.get_server().base_dir, "generated", "models") if not os.path.exists(models_dir): os.makedirs(models_dir) @@ -139,33 +145,38 @@ def UploadModel(): # Generate a uuid for the model saving saving_id = str(uuid.uuid4()) - code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.py') + code_path = os.path.join( + RServer.get_server().base_dir, "generated", "models", f"{saving_id}.py" + ) - metadata_4_save = {'class_name': None, - 'nickname': None, - 'description': None, - 'architecture': None, - 'tags': None, - 'create_time': None, - 'weight_path': None, - 'code_path': None, - 'epoch': None, - 'train_accuracy': None, - 'val_accuracy': None, - 'test_accuracy': None, - 'last_eval_on_dev_set': None, - 'last_eval_on_test_set': None - } + metadata_4_save = { + "class_name": None, + "nickname": None, + "description": None, + "architecture": None, + "tags": None, + "create_time": None, + "weight_path": None, + "code_path": None, + "epoch": None, + "train_accuracy": None, + "val_accuracy": None, + "test_accuracy": None, + "last_eval_on_dev_set": None, + "last_eval_on_test_set": None, + } # Get the model's class name - class_name = metadata.get('class_name') + class_name = metadata.get("class_name") + + predefined = bool(int(metadata.get("predefined"))) - # If the model is custom(i.e. it has code definition) - if 'code' in request.form: + # If the model is custom + if not predefined: # Get the model definition code and save it to a temporary file - code = request.form.get('code') + code = request.form.get("code") try: - with open(code_path, 'w') as code_file: + with open(code_path, "w") as code_file: code_file.write(code) except Exception as e: clear_model_temp_files(saving_id) @@ -177,23 +188,27 @@ def UploadModel(): except Exception as e: clear_model_temp_files(saving_id) return RResponse.abort(400, f"Failed to initialize the custom model. {e}") - elif 'pretrained' in metadata: # If the model is predefined - pretrained = bool(int(metadata.get('pretrained'))) - num_classes = int(metadata.get('num_classes')) + elif predefined: # If the model is predefined + pretrained = bool(int(metadata.get("pretrained"))) + num_classes = int(metadata.get("num_classes")) try: model = init_predefined_model(class_name, pretrained, num_classes) - with open(code_path, 'w') as code_file: + with open(code_path, "w") as code_file: code_file.write(f"num_classes = {num_classes}") except Exception as e: - return RResponse.abort(400, f"Failed to initialize the predefined model. {e}") + return RResponse.abort( + 400, f"Failed to initialize the predefined model. {e}" + ) else: return RResponse.abort(400, "The model is neither custom nor predefined.") # Get the weight file and save it to a temporary location if it exists - if 'weight_file' in request.files: - weight_file = request.files.get('weight_file') + if "weight_file" in request.files: + weight_file = request.files.get("weight_file") try: - weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.pth') + weight_path = os.path.join( + RServer.get_server().base_dir, "generated", "models", f"{saving_id}.pth" + ) weight_file.save(weight_path) except Exception as e: clear_model_temp_files(saving_id) @@ -205,9 +220,11 @@ def UploadModel(): except Exception as e: clear_model_temp_files(saving_id) return RResponse.abort(400, f"Failed to load the weights. {e}") - else: # If the weight file is not provided, save the current weights to a temporary location + else: # If the weight file is not provided, save the current weights to a temporary location try: - weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.pth') + weight_path = os.path.join( + RServer.get_server().base_dir, "generated", "models", f"{saving_id}.pth" + ) torch.save(model.state_dict(), weight_path) except Exception as e: clear_model_temp_files(saving_id) @@ -221,25 +238,28 @@ def UploadModel(): return RResponse.abort(400, f"The model is invalid. {e}") # Update the metadata for saving - metadata_4_save['class_name'] = class_name - metadata_4_save['nickname'] = metadata.get('nickname') - metadata_4_save['description'] = metadata.get('description') if metadata.get('description') else None - metadata_4_save['tags'] = metadata.get('tags') if metadata.get('tags') else None - metadata_4_save['create_time'] = datetime.now() - metadata_4_save['code_path'] = code_path - metadata_4_save['weight_path'] = weight_path - metadata_4_save['epoch'] = 0 - metadata_4_save['train_accuracy'] = None - metadata_4_save['val_accuracy'] = None - metadata_4_save['test_accuracy'] = None - metadata_4_save['last_eval_on_dev_set'] = None - metadata_4_save['last_eval_on_test_set'] = None + metadata_4_save["class_name"] = class_name + metadata_4_save["nickname"] = metadata.get("nickname") + metadata_4_save["predefined"] = predefined + metadata_4_save["description"] = ( + metadata.get("description") if metadata.get("description") else None + ) + metadata_4_save["tags"] = metadata.get("tags") if metadata.get("tags") else None + metadata_4_save["create_time"] = datetime.now() + metadata_4_save["code_path"] = code_path + metadata_4_save["weight_path"] = weight_path + metadata_4_save["epoch"] = 0 + metadata_4_save["train_accuracy"] = None + metadata_4_save["val_accuracy"] = None + metadata_4_save["test_accuracy"] = None + metadata_4_save["last_eval_on_dev_set"] = None + metadata_4_save["last_eval_on_test_set"] = None # Save the model's architecture to the metadata buffer = io.StringIO() with contextlib.redirect_stdout(buffer): print(model) - metadata_4_save['architecture'] = buffer.getvalue() + metadata_4_save["architecture"] = buffer.getvalue() # Save the model's metadata to the database try: @@ -253,12 +273,12 @@ def UploadModel(): except Exception as e: return RResponse.abort(500, f"Failed to set the current model. {e}") - return RResponse.ok('Success') + return RResponse.ok("Success") @model_api.route("/model/list", methods=["GET"]) def GetAllModels(): - """ return data + """return data [ { id: string, @@ -269,4 +289,3 @@ def GetAllModels(): ] """ pass - diff --git a/back-end/database/model.py b/back-end/database/model.py index 8a6a3781..ebc75e38 100644 --- a/back-end/database/model.py +++ b/back-end/database/model.py @@ -18,9 +18,9 @@ class EvalResults(db.Model): db.Column("influ_path", db.Integer), ) model_tag_rel = db.Table( - 'model_tag_rel', - db.Column('model_id', db.Integer, db.ForeignKey('models.id'), primary_key=True), - db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True) + "model_tag_rel", + db.Column("model_id", db.Integer, db.ForeignKey("models.id"), primary_key=True), + db.Column("tag_id", db.Integer, db.ForeignKey("tags.id"), primary_key=True), ) @@ -28,9 +28,10 @@ class Models(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) class_name = db.Column(db.String) nickname = db.Column(db.String) + predefined = db.Column(db.Boolean) description = db.Column(db.String) architecture = db.Column(db.String) - tags = db.relationship('Tags', secondary=model_tag_rel, backref='models') + tags = db.relationship("Tags", secondary=model_tag_rel, backref="models") create_time = db.Column(db.DateTime) weight_path = db.Column(db.String) code_path = db.Column(db.String) From e5174dc7b7068748d3b827a0173dd1d3371afe85 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Tue, 31 Oct 2023 23:22:04 -0400 Subject: [PATCH 54/58] Cherry-pick model refactors from refactor/model-db-upload-val Selected changes from: - ./backend/apis/model.py - ./backend/utils/model_utils.py --- back-end/apis/model.py | 243 +++++++++++++++------------------- back-end/utils/model_utils.py | 240 ++++++++++++++++++++++++++------- 2 files changed, 295 insertions(+), 188 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index da70ff51..0bd2571e 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -1,11 +1,8 @@ import json import string import uuid -import io -import contextlib from flask import request from flask import Blueprint -from datetime import datetime from utils.model_utils import * from objects.RResponse import RResponse from objects.RServer import RServer @@ -15,7 +12,7 @@ @model_api.route("/model/current", methods=["GET"]) def GetCurrModel(): - """ Get the model that is currently active """ + """Get the model that is currently active""" """ return data { id: string, @@ -28,13 +25,13 @@ def GetCurrModel(): @model_api.route("/model/current/", methods=["POST"]) def SetCurrModel(model_id: string): - """ return 200 on success """ + """return 200 on success""" pass @model_api.route("/model/", methods=["DELETE"]) def DeleteModel(): - """ return data + """return data { id: string, name: string, @@ -89,6 +86,7 @@ def UploadModel(): description: | Indicates if a predefined model is being used. "1" represents predefined, "0" otherwise. + required: true description: type: "string" description: "A description of the model (optional)." @@ -103,7 +101,7 @@ def UploadModel(): Indicates if a predefined model uses pretrained weights. "1" represents pretrained, "0" otherwise (required if predefined). num_classes: - type: "integer" + type: "string" description: "The number of classes for the predefined model (required if predefined)." responses: @@ -132,152 +130,119 @@ def UploadModel(): example: "Success" """ - # Get the model's metadata - metadata = json.loads(request.form.get("metadata")) - - # Check if the folder for saving models exists, if not, create it - if not os.path.exists(models_dir): - os.makedirs(models_dir) - - print("Requested to upload a new model") - - # Generate a uuid for the model saving - saving_id = str(uuid.uuid4()) - - code_path = os.path.join( - RServer.get_server().base_dir, "generated", "models", f"{saving_id}.py" - ) - - metadata_4_save = { - "class_name": None, - "nickname": None, - "description": None, - "architecture": None, - "tags": None, - "create_time": None, - "weight_path": None, - "code_path": None, - "epoch": None, - "train_accuracy": None, - "val_accuracy": None, - "test_accuracy": None, - "last_eval_on_dev_set": None, - "last_eval_on_test_set": None, - } - - # Get the model's class name - class_name = metadata.get("class_name") - - predefined = bool(int(metadata.get("predefined"))) - - # If the model is custom - if not predefined: - # Get the model definition code and save it to a temporary file - code = request.form.get("code") - try: - with open(code_path, "w") as code_file: - code_file.write(code) - except Exception as e: - clear_model_temp_files(saving_id) - return RResponse.abort(500, f"Failed to save the model definition. {e}") - - # Initialize the custom model - try: - model = init_custom_model(code_path, class_name) - except Exception as e: - clear_model_temp_files(saving_id) - return RResponse.abort(400, f"Failed to initialize the custom model. {e}") - elif predefined: # If the model is predefined - pretrained = bool(int(metadata.get("pretrained"))) - num_classes = int(metadata.get("num_classes")) - try: - model = init_predefined_model(class_name, pretrained, num_classes) - with open(code_path, "w") as code_file: - code_file.write(f"num_classes = {num_classes}") - except Exception as e: - return RResponse.abort( - 400, f"Failed to initialize the predefined model. {e}" - ) - else: - return RResponse.abort(400, "The model is neither custom nor predefined.") - - # Get the weight file and save it to a temporary location if it exists - if "weight_file" in request.files: - weight_file = request.files.get("weight_file") - try: - weight_path = os.path.join( - RServer.get_server().base_dir, "generated", "models", f"{saving_id}.pth" + code_path = None + weight_path = None + try: + # Get the model's metadata + metadata_str = request.form.get("metadata") + if metadata_str is None: + return RResponse.fail(f"The model metadata is missing.", 400) + metadata = json.loads(metadata_str) + + # Precheck the request + errors = precheck_request_4_upload_model(request) + if len(errors) > 0: + error_message = "; ".join(errors) + return RResponse.fail(f"Request validation failed: {error_message}", 400) + + create_models_dir() + + print("Requested to upload a new model") + + # Generate a uuid for the model saving + saving_id = str(uuid.uuid4()) + + code_path = os.path.join( + RServer.get_server().base_dir, + "generated", + "models", + "code", + f"{saving_id}.py", + ) + weight_path = os.path.join( + RServer.get_server().base_dir, + "generated", + "models", + "ckpt", + f"{saving_id}.pth", + ) + + # Get the model's class name + class_name = metadata.get("class_name") + + predefined = bool(int(metadata.get("predefined"))) + + # Save the model's code definition and initialize the model + if not predefined: # If the model is custom + # Get the model definition code and save it to a temporary file + code = request.form.get("code") + save_code(code, code_path) + # Initialize the model + try: + model = init_custom_model(code_path, class_name) + except Exception as e: + clear_model_temp_files(code_path, weight_path) + return RResponse.fail( + f"Failed to initialize the custom model. {e}", 400 + ) + elif predefined: # If the model is predefined + pretrained = bool(int(metadata.get("pretrained"))) + num_classes = int(metadata.get("num_classes")) + code = f"num_classes = {num_classes}" + save_code(code, code_path) + try: + model = init_predefined_model(class_name, pretrained, num_classes) + except Exception as e: + clear_model_temp_files(code_path, weight_path) + return RResponse.fail( + f"Failed to initialize the predefined model. {e}", 400 + ) + else: + return RResponse.fail( + "Invalid request. The model is neither custom nor predefined.", 400 ) - weight_file.save(weight_path) - except Exception as e: - clear_model_temp_files(saving_id) - return RResponse.abort(500, f"Failed to save the weight file. {e}") - # Load the weights from the file + # Get the weight file and save it to a temporary location if it exists + if "weight_file" in request.files: + weight_file = request.files.get("weight_file") + save_ckpt_weight(weight_file, weight_path) + # Load and validate the weights from the file + try: + load_ckpt_weight(model, weight_path) + except Exception as e: + clear_model_temp_files(code_path, weight_path) + return RResponse.fail(f"Failed to load the weights. {e}", 400) + else: # If the weight file is not provided, save the current weights to a temporary location + save_cur_weight(model, weight_path) + + # Validate the model try: - model.load_state_dict(torch.load(weight_path)) + val_model(model) except Exception as e: - clear_model_temp_files(saving_id) - return RResponse.abort(400, f"Failed to load the weights. {e}") - else: # If the weight file is not provided, save the current weights to a temporary location - try: - weight_path = os.path.join( - RServer.get_server().base_dir, "generated", "models", f"{saving_id}.pth" - ) - torch.save(model.state_dict(), weight_path) - except Exception as e: - clear_model_temp_files(saving_id) - return RResponse.abort(500, f"Failed to save the weight file. {e}") - - # Validate the model - try: - val_model(model) - except Exception as e: - clear_model_temp_files(saving_id) - return RResponse.abort(400, f"The model is invalid. {e}") - - # Update the metadata for saving - metadata_4_save["class_name"] = class_name - metadata_4_save["nickname"] = metadata.get("nickname") - metadata_4_save["predefined"] = predefined - metadata_4_save["description"] = ( - metadata.get("description") if metadata.get("description") else None - ) - metadata_4_save["tags"] = metadata.get("tags") if metadata.get("tags") else None - metadata_4_save["create_time"] = datetime.now() - metadata_4_save["code_path"] = code_path - metadata_4_save["weight_path"] = weight_path - metadata_4_save["epoch"] = 0 - metadata_4_save["train_accuracy"] = None - metadata_4_save["val_accuracy"] = None - metadata_4_save["test_accuracy"] = None - metadata_4_save["last_eval_on_dev_set"] = None - metadata_4_save["last_eval_on_test_set"] = None + clear_model_temp_files(code_path, weight_path) + return RResponse.fail(f"The model is invalid. {e}", 400) - # Save the model's architecture to the metadata - buffer = io.StringIO() - with contextlib.redirect_stdout(buffer): - print(model) - metadata_4_save["architecture"] = buffer.getvalue() + # Construct the metadata for saving + metadata_4_save = construct_metadata_4_save( + class_name, metadata, code_path, weight_path, model + ) - # Save the model's metadata to the database - try: + # Save the model's metadata to the database RServer.get_model_wrapper().create_model(metadata_4_save) - except Exception as e: - return RResponse.abort(500, f"Failed to save the model. {e}") - # Set the current model to the newly uploaded model - try: + # Set the current model to the newly uploaded model SetCurrModel(saving_id) - except Exception as e: - return RResponse.abort(500, f"Failed to set the current model. {e}") - return RResponse.ok("Success") + return RResponse.ok("Success") + except Exception as e: + if code_path is not None and weight_path is not None: + clear_model_temp_files(code_path, weight_path) + return RResponse.abort(500, f"Unexpected error. {e}") @model_api.route("/model/list", methods=["GET"]) def GetAllModels(): - """ return data + """return data [ { id: string, diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index 8714c4f0..24c3a446 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -2,105 +2,247 @@ import importlib import torch import torchvision +import io +import contextlib +import json from objects.RServer import RServer from utils.predict import get_image_prediction +from datetime import datetime IMAGENET_OUTPUT_SIZE = 1000 -PREDEFINED_MODELS = ['ResNet18', 'ResNet34', 'ResNet50', 'ResNet101', 'ResNet152', 'mobilenet-v2', 'ResNet18-32x32', - 'AlexNet'] +PREDEFINED_MODELS = [ + "ResNet18", + "ResNet34", + "ResNet50", + "ResNet101", + "ResNet152", + "mobilenet-v2", + "ResNet18-32x32", + "AlexNet", +] -# TODO: Use real model wrapper instead of dummy model wrapper -class DummyModelWrapper: +# TODO: Use the real model manager instead of dummy model manager +class DummyModelManager: def __init__(self, model): self.model = model self.device = RServer.get_model_wrapper().device def init_predefined_model(class_name, pretrained, num_classes): - """ Initialize the predefined model with the specified name - """ + """Initialize the predefined model with the specified name""" # Check if the model name is valid if class_name not in PREDEFINED_MODELS: raise Exception(f"Predefined model name {class_name} not recognized.") # If the model is pretrained, it should have the same number of classes as the ImageNet model if pretrained and num_classes != IMAGENET_OUTPUT_SIZE: - raise Exception(f"Pretrained model is supposed to have {IMAGENET_OUTPUT_SIZE} classes as output.") + raise Exception( + f"Pretrained model is supposed to have {IMAGENET_OUTPUT_SIZE} classes as output." + ) if class_name == "ResNet18": - model = torchvision.models.resnet18(pretrained=pretrained, num_classes=num_classes) + model = torchvision.models.resnet18( + pretrained=pretrained, num_classes=num_classes + ) elif class_name == "ResNet34": - model = torchvision.models.resnet34(pretrained=pretrained, num_classes=num_classes) + model = torchvision.models.resnet34( + pretrained=pretrained, num_classes=num_classes + ) elif class_name == "ResNet50": - model = torchvision.models.resnet50(pretrained=pretrained, num_classes=num_classes) + model = torchvision.models.resnet50( + pretrained=pretrained, num_classes=num_classes + ) elif class_name == "ResNet101": - model = torchvision.models.resnet101(pretrained=pretrained, num_classes=num_classes) + model = torchvision.models.resnet101( + pretrained=pretrained, num_classes=num_classes + ) elif class_name == "ResNet152": - model = torchvision.models.resnet152(pretrained=pretrained, num_classes=num_classes) + model = torchvision.models.resnet152( + pretrained=pretrained, num_classes=num_classes + ) elif class_name == "mobilenet-v2": - model = torchvision.models.mobilenet_v2(pretrained=pretrained, num_classes=num_classes) + model = torchvision.models.mobilenet_v2( + pretrained=pretrained, num_classes=num_classes + ) elif class_name == "ResNet18-32x32": - model = torchvision.models.ResNet(torchvision.models.resnet.BasicBlock, [2, 2, 2, 2], num_classes=num_classes) - model.conv1 = torch.nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) + model = torchvision.models.ResNet( + torchvision.models.resnet.BasicBlock, [2, 2, 2, 2], num_classes=num_classes + ) + model.conv1 = torch.nn.Conv2d( + 3, 64, kernel_size=3, stride=1, padding=1, bias=False + ) model.maxpool = torch.nn.MaxPool2d(kernel_size=3, stride=1, padding=1) if pretrained: raise Exception("Pretrained ResNet18-32x32 is not available.") elif class_name == "AlexNet": - model = torchvision.models.alexnet(pretrained=pretrained, num_classes=num_classes) + model = torchvision.models.alexnet( + pretrained=pretrained, num_classes=num_classes + ) return model def init_custom_model(code_path, class_name): - """ Initialize the custom model by importing the class with the specified name in the file specified by code_path - """ - try: - spec = importlib.util.spec_from_file_location("model_def", code_path) - model_def = importlib.util.module_from_spec(spec) - spec.loader.exec_module(model_def) - model = getattr(model_def, class_name)() - return model - except Exception as e: - print("Failed to initialize the model.") - print(e) - raise e - - -def clear_model_temp_files(saving_id): - """ Clear the temporary files associated with the model - """ - code_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.py') - weight_path = os.path.join(RServer.get_server().base_dir, 'generated', 'models', f'{saving_id}.pth') - try: - if os.path.exists(code_path): - os.remove(code_path) - if os.path.exists(weight_path): - os.remove(weight_path) - except Exception as e: - print("Failed to clear the temporary files associated with the model.") - print(e) - raise e + """Initialize the custom model by importing the class with the specified name in the file specified by code_path""" + spec = importlib.util.spec_from_file_location("model_def", code_path) + model_def = importlib.util.module_from_spec(spec) + spec.loader.exec_module(model_def) + model = getattr(model_def, class_name)() + return model + + +def precheck_request_4_upload_model(request): + errors = [] + + # Check for the presence of metadata + metadata_str = request.form.get("metadata") + if not metadata_str: + errors.append("The model metadata is missing.") + return errors + + metadata = json.loads(metadata_str) + + # Check for the presence of data + missing_keys = [] + required_keys = ["class_name", "nickname", "predefined"] + if metadata.get("predefined") == "1": + required_keys.extend(["pretrained", "num_classes"]) + for key in required_keys: + if key not in metadata: + missing_keys.append(key) + if missing_keys: + errors.append( + f"The following metadata fields are missing: {', '.join(missing_keys)}" + ) + + if metadata.get("predefined") == "0": + code = request.form.get("code") + if not code: + errors.append( + "Model definition code is missing but required when predefined is '0'." + ) + if metadata.get("pretrained") is not None: + errors.append("pretrained should not be specified when predefined is '0'.") + if metadata.get("pretrained") == "1": + weight_file = request.files.get("weight_file") + if weight_file: + errors.append("Weight file should not be specified when pretrained is '1'.") + + # Additional checks for metadata fields + if "class_name" in metadata and not isinstance(metadata["class_name"], str): + errors.append("class_name should be a string") + if "nickname" in metadata and not isinstance(metadata["nickname"], str): + errors.append("nickname should be a string") + if "predefined" in metadata: + if not isinstance(metadata["predefined"], str) or metadata[ + "predefined" + ] not in ["0", "1"]: + errors.append("predefined should be a string and either '0' or '1'") + if "description" in metadata and not isinstance(metadata["description"], str): + errors.append("description should be a string") + if "pretrained" in metadata: + if not isinstance(metadata["pretrained"], str) or metadata[ + "pretrained" + ] not in ["0", "1"]: + errors.append("pretrained should be a string and either '0' or '1'") + if "num_classes" in metadata: + if ( + not isinstance(metadata["num_classes"], str) + or not metadata["num_classes"].isdigit() + ): + errors.append("num_classes should be a string representation of an integer") + if "tags" in metadata and not ( + isinstance(metadata["tags"], list) + and all(isinstance(tag, str) for tag in metadata["tags"]) + ): + errors.append("tags should be a list of strings") + + return errors + + +def clear_model_temp_files(code_path, weight_path): + """Clear the temporary files associated with the model""" + if os.path.exists(code_path): + os.remove(code_path) + if os.path.exists(weight_path): + os.remove(weight_path) def val_model(model): - """ Validate the model by running the model against a small portion of the validation dataset - """ + """Validate the model by running the model against a small portion of the validation dataset""" # Get at most 10 samples from the validation dataset data_manager = RServer.get_data_manager() dataset = data_manager.validationset samples = dataset.samples[:10] # Create a dummy model wrapper to pass to the predict function - dummy_model_wrapper = DummyModelWrapper(model) - dummy_model_wrapper.model.eval() + dummy_model_manager = DummyModelManager(model) + dummy_model_manager.model.eval() # Run the model against the samples for img_path, label in samples: get_image_prediction( - dummy_model_wrapper, + dummy_model_manager, img_path, data_manager.image_size, argmax=False, ) + + +def create_models_dir(): + # Check if the folder for saving models exists, if not, create it + models_dir = os.path.join(RServer.get_server().base_dir, "generated", "models") + if not os.path.exists(models_dir): + os.makedirs(models_dir) + if not os.path.exists(os.path.join(models_dir, "code")): + os.makedirs(os.path.join(models_dir, "code")) + if not os.path.exists(os.path.join(models_dir, "ckpt")): + os.makedirs(os.path.join(models_dir, "ckpt")) + + +def save_code(code, code_path): + with open(code_path, "w") as code_file: + code_file.write(code) + + +def save_ckpt_weight(weight_file, weight_path): + weight_file.save(weight_path) + + +def load_ckpt_weight(model, weight_path): + model.load_state_dict(torch.load(weight_path)) + + +def save_cur_weight(model, weight_path): + torch.save(model.state_dict(), weight_path) + + +def construct_metadata_4_save(class_name, metadata, code_path, weight_path, model): + # Construct the metadata for saving + metadata_4_save = { + "class_name": class_name, + "nickname": metadata.get("nickname"), + "description": metadata.get("description") + if metadata.get("description") + else None, + "tags": metadata.get("tags") if metadata.get("tags") else None, + "create_time": datetime.now(), + "code_path": code_path, + "weight_path": weight_path, + "epoch": 0, + "train_accuracy": None, + "val_accuracy": None, + "test_accuracy": None, + "last_eval_on_dev_set": None, + "last_eval_on_test_set": None, + } + + # Save the model's architecture to the metadata + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + print(model) + metadata_4_save["architecture"] = buffer.getvalue() + + return metadata_4_save From 9d28e6c3c40a261b5eb55f887d78ea16d45d6f2d Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Fri, 3 Nov 2023 13:49:32 -0400 Subject: [PATCH 55/58] Add one TODO --- back-end/apis/model.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index 0bd2571e..18dec659 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -205,6 +205,8 @@ def UploadModel(): # Get the weight file and save it to a temporary location if it exists if "weight_file" in request.files: weight_file = request.files.get("weight_file") + # TODO: Use save_cur_weight() to save the weight of the model after it loads the ckpt to avoid potential + # inconsistency of the weight's location and the used device save_ckpt_weight(weight_file, weight_path) # Load and validate the weights from the file try: From d958b8675bc99851b40b619cd37769f5622143bb Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Fri, 3 Nov 2023 14:07:56 -0400 Subject: [PATCH 56/58] Remove one TODO --- back-end/utils/model_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index 24c3a446..29623793 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -24,7 +24,6 @@ ] -# TODO: Use the real model manager instead of dummy model manager class DummyModelManager: def __init__(self, model): self.model = model From 7dc199e6318aad0f0adb980a370898486619abb8 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Sun, 5 Nov 2023 22:58:25 -0500 Subject: [PATCH 57/58] Add `pretrained` as a new field in 'models' table & Stop saving weights for models uploaded without an explicit weight file --- back-end/apis/model.py | 23 +++++++++-------------- back-end/database/model.py | 1 + back-end/objects/RModelWrapper.py | 5 +++-- back-end/utils/model_utils.py | 17 +++++++++++------ 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index f1748456..e3e8069a 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -165,13 +165,6 @@ def UploadModel(): "code", f"{saving_id}.py", ) - weight_path = os.path.join( - RServer.get_server().base_dir, - "generated", - "models", - "ckpt", - f"{saving_id}.pth", - ) # Get the model's class name class_name = metadata.get("class_name") @@ -220,9 +213,14 @@ def UploadModel(): # Get the weight file and save it to a temporary location if it exists if "weight_file" in request.files: + weight_path = os.path.join( + RServer.get_server().base_dir, + "generated", + "models", + "ckpt", + f"{saving_id}.pth", + ) weight_file = request.files.get("weight_file") - # TODO: Use save_cur_weight() to save the weight of the model after it loads the ckpt to avoid potential - # inconsistency of the weight's location and the used device save_ckpt_weight(weight_file, weight_path) # Load and validate the weights from the file try: @@ -231,8 +229,6 @@ def UploadModel(): traceback.print_exc() clear_model_temp_files(code_path, weight_path) return RResponse.fail(f"Failed to load the weights. {e}", 400) - else: # If the weight file is not provided, save the current weights to a temporary location - save_cur_weight(model, weight_path) # Validate the model try: @@ -247,7 +243,7 @@ def UploadModel(): # Construct the metadata for saving metadata_4_save = construct_metadata_4_save( - class_name, metadata, code_path, weight_path, model + metadata, code_path, weight_path, model ) # Save the model's metadata to the database @@ -259,8 +255,7 @@ def UploadModel(): return RResponse.ok("Success") except Exception as e: traceback.print_exc() - if code_path is not None and weight_path is not None: - clear_model_temp_files(code_path, weight_path) + clear_model_temp_files(code_path, weight_path) return RResponse.abort(500, f"Unexpected error. {e}") diff --git a/back-end/database/model.py b/back-end/database/model.py index 8db1aa31..f7816acc 100644 --- a/back-end/database/model.py +++ b/back-end/database/model.py @@ -32,6 +32,7 @@ class Models(db.Model): class_name = db.Column(db.String) nickname = db.Column(db.String) predefined = db.Column(db.Boolean) + pretrained = db.Column(db.Boolean) description = db.Column(db.String) architecture = db.Column(db.String) tags = db.relationship("Tags", secondary=model_tag_rel, backref="models") diff --git a/back-end/objects/RModelWrapper.py b/back-end/objects/RModelWrapper.py index 76f58151..a3022310 100644 --- a/back-end/objects/RModelWrapper.py +++ b/back-end/objects/RModelWrapper.py @@ -164,7 +164,7 @@ def load_model_by_name(self, model_name: str): model = RModelWrapper.init_predefined_model( model_meta_data.class_name, - False, + model_meta_data.pretrained, num_classes, self.device, ) @@ -172,7 +172,8 @@ def load_model_by_name(self, model_name: str): model = RModelWrapper.init_custom_model( model_meta_data.code_path, model_meta_data.class_name, self.device ) - model.load_state_dict(torch.load(model_meta_data.weight_path)) + if model_meta_data.weight_path: + model.load_state_dict(torch.load(model_meta_data.weight_path)) return model, model_meta_data @staticmethod diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index e4b498d0..3d391d3e 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -86,10 +86,12 @@ def precheck_request_4_upload_model(request): def clear_model_temp_files(code_path, weight_path): """Clear the temporary files associated with the model""" - if os.path.exists(code_path): - os.remove(code_path) - if os.path.exists(weight_path): - os.remove(weight_path) + if code_path: + if os.path.exists(code_path): + os.remove(code_path) + if weight_path: + if os.path.exists(weight_path): + os.remove(weight_path) def val_model(model_wrapper: DummyModelWrapper): @@ -139,12 +141,15 @@ def save_cur_weight(model, weight_path): torch.save(model.state_dict(), weight_path) -def construct_metadata_4_save(class_name, metadata, code_path, weight_path, model): +def construct_metadata_4_save(metadata, code_path, weight_path, model): # Construct the metadata for saving metadata_4_save = { - "class_name": class_name, + "class_name": metadata.get("class_name"), "nickname": metadata.get("nickname"), "predefined": bool(int(metadata.get("predefined"))), + "pretrained": bool(int(metadata.get("pretrained"))) + if metadata.get("pretrained") + else None, "description": metadata.get("description"), "tags": metadata.get("tags"), "create_time": datetime.now(), From e0284989931b8b11b7c94eb0494ffae9654615f1 Mon Sep 17 00:00:00 2001 From: Leon-Leyang Date: Mon, 6 Nov 2023 00:16:06 -0500 Subject: [PATCH 58/58] Set `pretrained` as required field for metadata --- back-end/apis/model.py | 6 +++--- back-end/utils/model_utils.py | 22 ++++++---------------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/back-end/apis/model.py b/back-end/apis/model.py index e3e8069a..d41b0eb2 100644 --- a/back-end/apis/model.py +++ b/back-end/apis/model.py @@ -106,11 +106,11 @@ def UploadModel(): pretrained: type: "string" description: | - Indicates if a predefined model uses pretrained weights. - "1" represents pretrained, "0" otherwise (required if predefined). + Indicates whether the model is pretrained. + Should only be set to "1" if the model is predefined and pretrained. num_classes: type: "string" - description: "The number of classes for the predefined model (required if predefined)." + description: "The number of classes for the predefined model (will soon be removed)." responses: 200: diff --git a/back-end/utils/model_utils.py b/back-end/utils/model_utils.py index 3d391d3e..cc5656f4 100644 --- a/back-end/utils/model_utils.py +++ b/back-end/utils/model_utils.py @@ -28,9 +28,7 @@ def precheck_request_4_upload_model(request): # Check for the presence of data missing_keys = [] - required_keys = ["class_name", "nickname", "predefined"] - if metadata.get("predefined") == "1": - required_keys.extend(["pretrained", "num_classes"]) + required_keys = ["class_name", "nickname", "predefined", "pretrained"] for key in required_keys: if key not in metadata: missing_keys.append(key) @@ -45,8 +43,6 @@ def precheck_request_4_upload_model(request): errors.append( "Model definition code is missing but required when predefined is '0'." ) - if metadata.get("pretrained") is not None: - errors.append("pretrained should not be specified when predefined is '0'.") if metadata.get("pretrained") == "1": weight_file = request.files.get("weight_file") if weight_file: @@ -58,17 +54,13 @@ def precheck_request_4_upload_model(request): if "nickname" in metadata and not isinstance(metadata["nickname"], str): errors.append("nickname should be a string") if "predefined" in metadata: - if not isinstance(metadata["predefined"], str) or metadata[ - "predefined" - ] not in ["0", "1"]: - errors.append("predefined should be a string and either '0' or '1'") + if metadata["predefined"] not in ["0", "1"]: + errors.append("predefined should be a either '0' or '1'") if "description" in metadata and not isinstance(metadata["description"], str): errors.append("description should be a string") if "pretrained" in metadata: - if not isinstance(metadata["pretrained"], str) or metadata[ - "pretrained" - ] not in ["0", "1"]: - errors.append("pretrained should be a string and either '0' or '1'") + if metadata["pretrained"] not in ["0", "1"]: + errors.append("pretrained should be a either '0' or '1'") if "num_classes" in metadata: if ( not isinstance(metadata["num_classes"], str) @@ -147,9 +139,7 @@ def construct_metadata_4_save(metadata, code_path, weight_path, model): "class_name": metadata.get("class_name"), "nickname": metadata.get("nickname"), "predefined": bool(int(metadata.get("predefined"))), - "pretrained": bool(int(metadata.get("pretrained"))) - if metadata.get("pretrained") - else None, + "pretrained": bool(int(metadata.get("pretrained"))), "description": metadata.get("description"), "tags": metadata.get("tags"), "create_time": datetime.now(),