From b364ff7c621a99c835bef9c21d9f7dad453aab8e Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 8 Oct 2020 11:01:57 -0400 Subject: [PATCH 001/263] TEMP initial config classes --- linajea/configuration/configurations.py | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 linajea/configuration/configurations.py diff --git a/linajea/configuration/configurations.py b/linajea/configuration/configurations.py new file mode 100644 index 0000000..9aa9ca1 --- /dev/null +++ b/linajea/configuration/configurations.py @@ -0,0 +1,64 @@ +from dataclasses import dataclass +from typing import List +from linajea import load_config + + +class LinajeaConfig: + + def __init__(self, config_file): + config_dict = load_config(config_file) + self.general = GeneralConfig(**config_dict['general']) + self.predict = PredictConfig(**config_dict['predict']) + self.extract = ExtractConfig(**config_dict['extract_edges']) + self.evaluate = EvaluateConfig(**config_dict['evaluate']) + + +@dataclass +class GeneralConfig: + setup: str + iteration: int + sample: str + db_host: str + db_name: str + prediction_type: str + singularity_image: str + queue: str + data_dir: str + setups_dir: str + frames: List[int] + + +@dataclass +class PredictConfig: + cell_score_threshold: float + num_workers: int + + +@dataclass +class ExtractConfig: + edge_move_threshold: int + block_size: List[int] + num_workers: int + + +@dataclass +class SolveConfig: + # TODO: is this the same as tracking parameters? + cost_appear: float + cost_disappear: float + cost_split: float + threshold_node_score: float + weight_node_score: float + threshold_edge_score: float + weight_distance_cost: float + weight_prediction_distance_cost: float + block_size: List[int] + context: List[int] + num_workers: int + from_scratch: bool + + +@dataclass +class EvaluateConfig: + gt_db_name: str + from_scratch: bool From c7d8be0054cadab305abeeb1d3a3d571c1b2c757 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 11 Feb 2021 11:29:59 -0500 Subject: [PATCH 002/263] Split configs into different files --- linajea/config/__init__.py | 7 +++ linajea/config/evaluate.py | 8 ++++ linajea/config/extract.py | 9 ++++ linajea/config/general.py | 23 +++++++++ linajea/config/linajea_config.py | 15 ++++++ linajea/config/predict.py | 8 ++++ linajea/config/solve.py | 19 ++++++++ linajea/configuration/configurations.py | 64 ------------------------- 8 files changed, 89 insertions(+), 64 deletions(-) create mode 100644 linajea/config/__init__.py create mode 100644 linajea/config/evaluate.py create mode 100644 linajea/config/extract.py create mode 100644 linajea/config/general.py create mode 100644 linajea/config/linajea_config.py create mode 100644 linajea/config/predict.py create mode 100644 linajea/config/solve.py delete mode 100644 linajea/configuration/configurations.py diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py new file mode 100644 index 0000000..9eb577f --- /dev/null +++ b/linajea/config/__init__.py @@ -0,0 +1,7 @@ +# flake8: noqa +from .linajea_config import LinajeaConfig +from .general import GeneralConfig +from .predict import PredictConfig +from .extract import ExtractConfig +from .solve import SolveConfig +from .evaluate import EvaluateConfig diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py new file mode 100644 index 0000000..1eba648 --- /dev/null +++ b/linajea/config/evaluate.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass +class EvaluateConfig: + gt_db_name: str + from_scratch: bool + matching_threshold: int diff --git a/linajea/config/extract.py b/linajea/config/extract.py new file mode 100644 index 0000000..47b93b0 --- /dev/null +++ b/linajea/config/extract.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from typing import List + + +@dataclass +class ExtractConfig: + edge_move_threshold: int + block_size: List[int] + num_workers: int diff --git a/linajea/config/general.py b/linajea/config/general.py new file mode 100644 index 0000000..1361cee --- /dev/null +++ b/linajea/config/general.py @@ -0,0 +1,23 @@ +import attr +from attr import validators +from typing import List + + +@attr.s +class GeneralConfig: + setup = attr.ib(type=str) + iteration = attr.ib(type=int) + sample = attr.ib(type=str) + db_host = attr.ib(type=str) + db_name = attr.ib(type=str) + singularity_image = attr.ib(type=str) + data_dir = attr.ib(type=str) + data_file = attr.ib(type=str) + setups_dir = attr.ib(type=str) + frames = attr.ib( + type=List[int], + validator=validators.deep_iterable( + member_validator=validators.instance_of(int), + iterable_validator=validators.instance_of(list))) + queue = attr.ib(type=str, default='gpu_rtx') + lab = attr.ib(type=str, default='funke') diff --git a/linajea/config/linajea_config.py b/linajea/config/linajea_config.py new file mode 100644 index 0000000..ee6ab14 --- /dev/null +++ b/linajea/config/linajea_config.py @@ -0,0 +1,15 @@ +from .general import GeneralConfig +from .predict import PredictConfig +from .extract import ExtractConfig +from .evaluate import EvaluateConfig +from linajea import load_config + + +class LinajeaConfig: + + def __init__(self, config_file): + config_dict = load_config(config_file) + self.general = GeneralConfig(**config_dict['general']) + self.predict = PredictConfig(**config_dict['predict']) + self.extract = ExtractConfig(**config_dict['extract_edges']) + self.evaluate = EvaluateConfig(**config_dict['evaluate']) diff --git a/linajea/config/predict.py b/linajea/config/predict.py new file mode 100644 index 0000000..e83e678 --- /dev/null +++ b/linajea/config/predict.py @@ -0,0 +1,8 @@ +import attr + + +@attr.s +class PredictConfig: + cell_score_threshold = attr.ib(type=float) + num_workers = attr.ib(type=int) + processes_per_worker = attr.ib(type=int) diff --git a/linajea/config/solve.py b/linajea/config/solve.py new file mode 100644 index 0000000..580991d --- /dev/null +++ b/linajea/config/solve.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import List + + +@dataclass +class SolveConfig: + # TODO: is this the same as tracking parameters? + cost_appear: float + cost_disappear: float + cost_split: float + threshold_node_score: float + weight_node_score: float + threshold_edge_score: float + weight_distance_cost: float + weight_prediction_distance_cost: float + block_size: List[int] + context: List[int] + num_workers: int + from_scratch: bool diff --git a/linajea/configuration/configurations.py b/linajea/configuration/configurations.py deleted file mode 100644 index 9aa9ca1..0000000 --- a/linajea/configuration/configurations.py +++ /dev/null @@ -1,64 +0,0 @@ -from dataclasses import dataclass -from typing import List -from linajea import load_config - - -class LinajeaConfig: - - def __init__(self, config_file): - config_dict = load_config(config_file) - self.general = GeneralConfig(**config_dict['general']) - self.predict = PredictConfig(**config_dict['predict']) - self.extract = ExtractConfig(**config_dict['extract_edges']) - self.evaluate = EvaluateConfig(**config_dict['evaluate']) - - -@dataclass -class GeneralConfig: - setup: str - iteration: int - sample: str - db_host: str - db_name: str - prediction_type: str - singularity_image: str - queue: str - data_dir: str - setups_dir: str - frames: List[int] - - -@dataclass -class PredictConfig: - cell_score_threshold: float - num_workers: int - - -@dataclass -class ExtractConfig: - edge_move_threshold: int - block_size: List[int] - num_workers: int - - -@dataclass -class SolveConfig: - # TODO: is this the same as tracking parameters? - cost_appear: float - cost_disappear: float - cost_split: float - threshold_node_score: float - weight_node_score: float - threshold_edge_score: float - weight_distance_cost: float - weight_prediction_distance_cost: float - block_size: List[int] - context: List[int] - num_workers: int - from_scratch: bool - - -@dataclass -class EvaluateConfig: - gt_db_name: str - from_scratch: bool From bc6a0c57859eeac845aa3dda11f7150cb33f6a97 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 11 Feb 2021 12:07:09 -0500 Subject: [PATCH 003/263] Update general config to match spec --- linajea/config/general.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/linajea/config/general.py b/linajea/config/general.py index 1361cee..0f7d5a5 100644 --- a/linajea/config/general.py +++ b/linajea/config/general.py @@ -1,23 +1,12 @@ import attr -from attr import validators -from typing import List @attr.s class GeneralConfig: setup = attr.ib(type=str) - iteration = attr.ib(type=int) - sample = attr.ib(type=str) - db_host = attr.ib(type=str) - db_name = attr.ib(type=str) - singularity_image = attr.ib(type=str) - data_dir = attr.ib(type=str) - data_file = attr.ib(type=str) setups_dir = attr.ib(type=str) - frames = attr.ib( - type=List[int], - validator=validators.deep_iterable( - member_validator=validators.instance_of(int), - iterable_validator=validators.instance_of(list))) - queue = attr.ib(type=str, default='gpu_rtx') - lab = attr.ib(type=str, default='funke') + db_host = attr.ib(type=str) + sample = attr.ib(type=str) + db_name = attr.ib(type=str, default=None) + singularity_image = attr.ib(type=str, default=None) + sparse = attr.ib(type=bool, default=True) From 470561f524d0a7809339759a3b5f5508549aa360 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 11 Feb 2021 12:15:51 -0500 Subject: [PATCH 004/263] Add data config to match spec --- linajea/config/__init__.py | 1 + linajea/config/data.py | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 linajea/config/data.py diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index 9eb577f..b9abb2d 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -1,6 +1,7 @@ # flake8: noqa from .linajea_config import LinajeaConfig from .general import GeneralConfig +from .data import DataConfig from .predict import PredictConfig from .extract import ExtractConfig from .solve import SolveConfig diff --git a/linajea/config/data.py b/linajea/config/data.py new file mode 100644 index 0000000..1d056e7 --- /dev/null +++ b/linajea/config/data.py @@ -0,0 +1,11 @@ +import attr +from typing import List + + +@attr.s +class DataConfig: + filename = attr.ib(type=str) + group = attr.ib(type=str, default=None) + voxel_size = attr.ib(type=List[int], default=None) + roi_offset = attr.ib(type=List[int], default=None) + roi_shape = attr.ib(type=List[int], default=None) From e34a748e883a0ae4375f4f8470779ed2fd3a4746 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 11 Feb 2021 14:36:59 -0500 Subject: [PATCH 005/263] Add Job config to match spec --- linajea/config/__init__.py | 1 + linajea/config/job.py | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 linajea/config/job.py diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index b9abb2d..fa5494e 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -2,6 +2,7 @@ from .linajea_config import LinajeaConfig from .general import GeneralConfig from .data import DataConfig +from .job import JobConfig from .predict import PredictConfig from .extract import ExtractConfig from .solve import SolveConfig diff --git a/linajea/config/job.py b/linajea/config/job.py new file mode 100644 index 0000000..b6b23e5 --- /dev/null +++ b/linajea/config/job.py @@ -0,0 +1,9 @@ +import attr + + +@attr.s +class JobConfig: + num_workers = attr.ib(type=int) + queue = attr.ib(type=str) + lab = attr.ib(type=str, default=None) + singularity_image = attr.ib(type=str, default=None) From 0b05145f51ebca5ec29bd0cfb9f3815f4ea1463f Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 11 Feb 2021 14:58:34 -0500 Subject: [PATCH 006/263] Update predict config to use nested classes --- linajea/config/predict.py | 11 +++++++++-- linajea/config/utils.py | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 linajea/config/utils.py diff --git a/linajea/config/predict.py b/linajea/config/predict.py index e83e678..a05fc5f 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -1,8 +1,15 @@ import attr +from .data import DataConfig +from .job import JobConfig +from .utils import ensure_cls @attr.s class PredictConfig: + data = attr.ib(converter=ensure_cls(DataConfig)) + job = attr.ib(converter=ensure_cls(JobConfig)) + iteration = attr.ib(type=int) cell_score_threshold = attr.ib(type=float) - num_workers = attr.ib(type=int) - processes_per_worker = attr.ib(type=int) + write_to_zarr = attr.ib(type=bool, default=False) + write_to_db = attr.ib(type=bool, default=True) + processes_per_worker = attr.ib(type=int, default=1) diff --git a/linajea/config/utils.py b/linajea/config/utils.py new file mode 100644 index 0000000..204f11d --- /dev/null +++ b/linajea/config/utils.py @@ -0,0 +1,8 @@ +def ensure_cls(cl): + """If the attribute is an instance of cls, pass, else try constructing.""" + def converter(val): + if isinstance(val, cl): + return val + else: + return cl(**val) + return converter From ce82969c547479dfd05e43359d6bb8fe6cd52f8e Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 11 Feb 2021 15:10:24 -0500 Subject: [PATCH 007/263] Update extract config to match spec --- linajea/config/extract.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/linajea/config/extract.py b/linajea/config/extract.py index 47b93b0..1d50205 100644 --- a/linajea/config/extract.py +++ b/linajea/config/extract.py @@ -1,9 +1,22 @@ -from dataclasses import dataclass -from typing import List +import attr +from typing import List, Dict +from .job import JobConfig +from .utils import ensure_cls -@dataclass + +def edge_move_converter(): + def converter(val): + if isinstance(val, int): + return {0: val} + else: + return val + return converter + + +@attr.s class ExtractConfig: - edge_move_threshold: int - block_size: List[int] - num_workers: int + edge_move_threshold = attr.ib(type=Dict[int, int], + converter=edge_move_converter()) + block_size = attr.ib(type=List[int]) + job = attr.ib(converter=ensure_cls(JobConfig)) From 0f881faa25a4ed64dff131e5c4639a9894637308 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 11 Feb 2021 15:10:56 -0500 Subject: [PATCH 008/263] Add informal test config and script --- linajea/config/config_test.py | 21 ++++++++++++++++++++ linajea/config/sample_config.toml | 32 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 linajea/config/config_test.py create mode 100644 linajea/config/sample_config.toml diff --git a/linajea/config/config_test.py b/linajea/config/config_test.py new file mode 100644 index 0000000..eb39542 --- /dev/null +++ b/linajea/config/config_test.py @@ -0,0 +1,21 @@ +from linajea import load_config +from linajea.config import ( + GeneralConfig, + DataConfig, + JobConfig, + PredictConfig, + ExtractConfig, + ) + +if __name__ == "__main__": + config_dict = load_config("sample_config.toml") + general_config = GeneralConfig(**config_dict['general']) + print(general_config) + data_config = DataConfig(**config_dict['data']) + print(data_config) + job_config = JobConfig(**config_dict['job']) + print(job_config) + predict_config = PredictConfig(**config_dict['predict']) + print(predict_config) + extract_config = ExtractConfig(**config_dict['extract']) + print(extract_config) diff --git a/linajea/config/sample_config.toml b/linajea/config/sample_config.toml new file mode 100644 index 0000000..49b7817 --- /dev/null +++ b/linajea/config/sample_config.toml @@ -0,0 +1,32 @@ +[general] +setup = "setup211_simple_train_side_2" +db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin" +db_name = "linajea_120828_setup211_simple_eval_side_1_400000" +sample = "120828" +setups_dir = "../unet_setups" + + +[data] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group='raw' + +[job] +num_workers = 16 +queue = 'gpu_rtx' + +[predict] +iteration = 100000 +cell_score_threshold = 0.4 +[predict.data] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group='raw' +[predict.job] +num_workers = 16 +queue = 'gpu_rtx' + +[extract] +edge_move_threshold = 1 +block_size = [5, 500, 500, 500] +[extract.job] +num_workers = 16 +queue = 'local' From 0436f5e3a44e97a8566d6f4db997d1a480f8121d Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 11 Feb 2021 15:12:15 -0500 Subject: [PATCH 009/263] Add optional context to extract config --- linajea/config/extract.py | 1 + 1 file changed, 1 insertion(+) diff --git a/linajea/config/extract.py b/linajea/config/extract.py index 1d50205..d50a873 100644 --- a/linajea/config/extract.py +++ b/linajea/config/extract.py @@ -20,3 +20,4 @@ class ExtractConfig: converter=edge_move_converter()) block_size = attr.ib(type=List[int]) job = attr.ib(converter=ensure_cls(JobConfig)) + context = attr.ib(type=List[int], default=None) From f91c41daa79db68bde56e1b0f828e245b6ead5df Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 11 Feb 2021 15:19:26 -0500 Subject: [PATCH 010/263] Update evaluate config to match spec, allow None in class converter --- linajea/config/config_test.py | 3 +++ linajea/config/evaluate.py | 16 +++++++++++----- linajea/config/sample_config.toml | 5 +++++ linajea/config/utils.py | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/linajea/config/config_test.py b/linajea/config/config_test.py index eb39542..bafc8df 100644 --- a/linajea/config/config_test.py +++ b/linajea/config/config_test.py @@ -5,6 +5,7 @@ JobConfig, PredictConfig, ExtractConfig, + EvaluateConfig, ) if __name__ == "__main__": @@ -19,3 +20,5 @@ print(predict_config) extract_config = ExtractConfig(**config_dict['extract']) print(extract_config) + evaluate_config = EvaluateConfig(**config_dict['evaluate']) + print(evaluate_config) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 1eba648..646684f 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -1,8 +1,14 @@ -from dataclasses import dataclass +import attr +from .data import DataConfig +from .job import JobConfig +from .utils import ensure_cls -@dataclass + +@attr.s class EvaluateConfig: - gt_db_name: str - from_scratch: bool - matching_threshold: int + gt_db_name = attr.ib(type=str) + matching_threshold = attr.ib(type=int) + data = attr.ib(converter=ensure_cls(DataConfig), default=None) + job = attr.ib(converter=ensure_cls(JobConfig), default=None) + from_scratch = attr.ib(type=bool, default=False) diff --git a/linajea/config/sample_config.toml b/linajea/config/sample_config.toml index 49b7817..5dd7a89 100644 --- a/linajea/config/sample_config.toml +++ b/linajea/config/sample_config.toml @@ -30,3 +30,8 @@ block_size = [5, 500, 500, 500] [extract.job] num_workers = 16 queue = 'local' + +[evaluate] +gt_db_name = 'linajea_120828_gt_side_1' +matching_threshold = 15 +from_scratch = true diff --git a/linajea/config/utils.py b/linajea/config/utils.py index 204f11d..c8d547c 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -1,7 +1,7 @@ def ensure_cls(cl): """If the attribute is an instance of cls, pass, else try constructing.""" def converter(val): - if isinstance(val, cl): + if isinstance(val, cl) or val is None: return val else: return cl(**val) From fcbb356ee79b9aace3f02bad814f8372fc2cd8e1 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 11 Feb 2021 15:33:29 -0500 Subject: [PATCH 011/263] Update solve config to match spec --- linajea/config/config_test.py | 3 +++ linajea/config/sample_config.toml | 15 ++++++++++++++ linajea/config/solve.py | 33 +++++++++++++++++-------------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/linajea/config/config_test.py b/linajea/config/config_test.py index bafc8df..234551c 100644 --- a/linajea/config/config_test.py +++ b/linajea/config/config_test.py @@ -5,6 +5,7 @@ JobConfig, PredictConfig, ExtractConfig, + SolveConfig, EvaluateConfig, ) @@ -20,5 +21,7 @@ print(predict_config) extract_config = ExtractConfig(**config_dict['extract']) print(extract_config) + solve_config = SolveConfig(**config_dict['solve']) + print(solve_config) evaluate_config = EvaluateConfig(**config_dict['evaluate']) print(evaluate_config) diff --git a/linajea/config/sample_config.toml b/linajea/config/sample_config.toml index 5dd7a89..80a657d 100644 --- a/linajea/config/sample_config.toml +++ b/linajea/config/sample_config.toml @@ -31,6 +31,21 @@ block_size = [5, 500, 500, 500] num_workers = 16 queue = 'local' +[solve] +cost_appear = 10000000000.0 +cost_disappear = 100000.0 +cost_split = 0 +threshold_node_score = 0.5 +weight_node_score = 100 +threshold_edge_score = 5 +weight_prediction_distance_cost = 1 +block_size = [ 5, 500, 500, 500,] +context = [ 2, 100, 100, 100,] +from_scratch = false +[solve.job] +num_workers = 8 +queue = 'cpu' + [evaluate] gt_db_name = 'linajea_120828_gt_side_1' matching_threshold = 15 diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 580991d..3afafad 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -1,19 +1,22 @@ -from dataclasses import dataclass +import attr from typing import List +from .job import JobConfig +from .utils import ensure_cls -@dataclass + +@attr.s class SolveConfig: - # TODO: is this the same as tracking parameters? - cost_appear: float - cost_disappear: float - cost_split: float - threshold_node_score: float - weight_node_score: float - threshold_edge_score: float - weight_distance_cost: float - weight_prediction_distance_cost: float - block_size: List[int] - context: List[int] - num_workers: int - from_scratch: bool + cost_appear = attr.ib(type=float) + cost_disappear = attr.ib(type=float) + cost_split = attr.ib(type=float) + threshold_node_score = attr.ib(type=float) + weight_node_score = attr.ib(type=float) + threshold_edge_score = attr.ib(type=float) + weight_prediction_distance_cost = attr.ib(type=float) + block_size = attr.ib(type=List[int]) + context = attr.ib(type=List[int]) + job = attr.ib(converter=ensure_cls(JobConfig)) + # max_cell_move: currently use edge_move_threshold from extract + max_cell_move = attr.ib(type=int, default=None) + from_scratch = attr.ib(type=bool, default=False) From f14c52fcb696d22c2f359ece10d741bfb5411a1d Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 15 Feb 2021 09:04:50 -0500 Subject: [PATCH 012/263] add augment config --- linajea/config/augment.py | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 linajea/config/augment.py diff --git a/linajea/config/augment.py b/linajea/config/augment.py new file mode 100644 index 0000000..2405048 --- /dev/null +++ b/linajea/config/augment.py @@ -0,0 +1,71 @@ +import attr +from typing import List + +from .utils import ensure_cls + + +@attr.s(kw_only=True) +class AugmentElasticConfig: + control_point_spacing = attr.ib(type=List[int]) + jitter_sigma = attr.ib(type=List[int]) + rotation_min = attr.ib(type=int) + rotation_max = attr.ib(type=int) + subsample = attr.ib(type=int) + + +@attr.s(kw_only=True) +class AugmentIntensityConfig: + scale = attr.ib(type=List[float]) + shift = attr.ib(type=List[float]) + + +@attr.s(kw_only=True) +class AugmentShiftConfig: + prob_slip = attr.ib(type=float) + prob_shift = attr.ib(type=float) + sigma = attr.ib(type=List[int]) + + +@attr.s(kw_only=True) +class AugmentSimpleConfig: + mirror = attr.ib(type=List[int]) + transpose = attr.ib(type=List[int]) + + +@attr.s(kw_only=True) +class AugmentNoiseGaussianConfig: + var = attr.ib(type=float) + + +@attr.s(kw_only=True) +class AugmentNoiseSaltPepperConfig: + amount = attr.ib(type=float) + + +@attr.s(kw_only=True) +class AugmentJitterConfig: + jitter = attr.ib(type=List[int]) + + +@attr.s(kw_only=True) +class AugmentConfig: + elastic = attr.ib(converter=ensure_cls(AugmentElasticConfig)) + shift = attr.ib(converter=ensure_cls(AugmentShiftConfig), + default=None) + intensity = attr.ib(converter=ensure_cls(AugmentIntensityConfig)) + simple = attr.ib(converter=ensure_cls(AugmentSimpleConfig)) + noise_gaussian = attr.ib(converter=ensure_cls(AugmentNoiseGaussianConfig)) + noise_saltpepper = attr.ib(converter=ensure_cls(AugmentNoiseSaltPepperConfig)) + + +@attr.s(kw_only=True) +class AugmentTrackingConfig(AugmentConfig): + reject_empty_prob = attr.ib(type=float) # (default=1.0?) + norm_bounds = attr.ib(type=List[int]) + divisions = attr.ib(type=bool) # float for percentage? + normalization = attr.ib(type=str, default=None) + + +@attr.s(kw_only=True) +class AugmentCellCycleConfig(AugmentConfig): + jitter = attr.ib(converter=ensure_cls(AugmentJitterConfig)) From a463d191d989b5464744baad7de30f3b8d5d8b6a Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 15 Feb 2021 09:05:03 -0500 Subject: [PATCH 013/263] add network configs --- linajea/config/cnn_config.py | 48 +++++++++++++++++++++++++++++++++++ linajea/config/unet_config.py | 24 ++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 linajea/config/cnn_config.py create mode 100644 linajea/config/unet_config.py diff --git a/linajea/config/cnn_config.py b/linajea/config/cnn_config.py new file mode 100644 index 0000000..0b7f5b9 --- /dev/null +++ b/linajea/config/cnn_config.py @@ -0,0 +1,48 @@ +import attr +from typing import List + +from .data import DataConfig +from .job import JobConfig +from .utils import ensure_cls + + +@attr.s(kw_only=True) +class CNNConfig: + # shape -> voxels, size -> world units + input_shape = attr.ib(type=List[int]) + pad_raw = attr.ib(type=List[int]) + activation = attr.ib(type=str, default='relu') + padding = attr.ib(type=str, default='SAME') + merge_time_voxel_size = attr.ib(type=int, default=1) + use_dropout = attr.ib(type=bool, default=True) + use_batchnorm = attr.ib(type=bool, default=True) + use_bias = attr.ib(type=bool, default=True) + use_global_pool = attr.ib(type=bool, default=True) + use_conv4d = attr.ib(type=bool, default=True) + num_classes = attr.ib(type=int, default=3) + network_type = attr.ib(type=str, default="vgg") + make_isotropic = attr.ib(type=int, default=False) + + +@attr.s(kw_only=True) +class VGGConfig(CNNConfig): + num_fmaps = attr.ib(type=List[int], default=32) + fmap_inc_factors = attr.ib(type=List[int]) + downsample_factors = attr.ib(type=List[List[int]]) + kernel_sizes = attr.ib(type=List[List[int]]) + fc_size = attr.ib(type=int) + net_name = attr.ib(type=str, default="vgg") + + +@attr.s(kw_only=True) +class ResNetConfig(CNNConfig): + net_name = attr.ib(type=str, default="resnet") + resnet_size = attr.ib(type=str, default="18") + num_blocks = attr.ib(default=None, type=List[int]) + use_bottleneck = attr.ib(default=None, type=bool) + + +@attr.s(kw_only=True) +class EfficientNetConfig(CNNConfig): + net_name = attr.ib(type=str, default="efficientnet") + efficientnet_size = attr.ib(type=str, default="B01") diff --git a/linajea/config/unet_config.py b/linajea/config/unet_config.py new file mode 100644 index 0000000..842a518 --- /dev/null +++ b/linajea/config/unet_config.py @@ -0,0 +1,24 @@ +import attr +from typing import List + +from .data import DataConfig +from .job import JobConfig +from .utils import ensure_cls + + +@attr.s(kw_only=True) +class UnetConfig: + # shape -> voxels, size -> world units + train_input_shape = attr.ib(type=List[int]) + predict_input_shape = attr.ib(type=List[int]) + fmap_inc_factors = attr.ib(type=int) + downsample_factors = attr.ib(type=List[List[int]]) + kernel_size_down = attr.ib(type=List[List[int]]) + kernel_size_up = attr.ib(type=List[List[int]]) + upsampling = attr.ib(type=str, default="uniform_transposed_conv") + constant_upsample = attr.ib(type=bool) + nms_window_shape = attr.ib(type=List[int]) + average_vectors = attr.ib(type=bool, default=False) + unet_style = attr.ib(type=str, default='split') + num_fmaps = attr.ib(type=int) + cell_indicator_weighted = attr.ib(type=bool, default=True) From 4329fffd7f3cca2a5b5fc7ad0d2601ba89ca70b2 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 15 Feb 2021 09:05:33 -0500 Subject: [PATCH 014/263] add optimizer config --- linajea/config/optimizer.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 linajea/config/optimizer.py diff --git a/linajea/config/optimizer.py b/linajea/config/optimizer.py new file mode 100644 index 0000000..f8a4297 --- /dev/null +++ b/linajea/config/optimizer.py @@ -0,0 +1,24 @@ +import attr +from typing import List + +from .utils import ensure_cls + + +@attr.s(kw_only=True) +class OptimizerArgsConfig: + learning_rate = attr.ib(type=float) + momentum = attr.ib(type=float, default=None) + + +@attr.s(kw_only=True) +class OptimizerKwargsConfig: + beta1 = attr.ib(type=float, default=None) + beta2 = attr.ib(type=float, default=None) + epsilon = attr.ib(type=float, default=None) + + +@attr.s(kw_only=True) +class OptimizerConfig: + optimizer = attr.ib(type=str, default="AdamOptimizer") + args = attr.ib(converter=ensure_cls(OptimizerArgsConfig)) + kwargs = attr.ib(converter=ensure_cls(OptimizerKwargsConfig)) From 92be83c03978d12ef136ef41c2654d4e572f65b0 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 15 Feb 2021 09:05:56 -0500 Subject: [PATCH 015/263] add train config --- linajea/config/train.py | 53 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 linajea/config/train.py diff --git a/linajea/config/train.py b/linajea/config/train.py new file mode 100644 index 0000000..996d9b5 --- /dev/null +++ b/linajea/config/train.py @@ -0,0 +1,53 @@ +import attr +from typing import Dict, List + +from .augment import (AugmentTrackingConfig, + AugmentCellCycleConfig) +from .data import (DataConfig, + DataDBConfig) +from .job import JobConfig +from .utils import ensure_cls + + +def use_radius_converter(): + def converter(val): + if isinstance(val, bool): + return {0: val} + else: + return val + return converter + + +@attr.s(kw_only=True) +class TrainConfig: + data = attr.ib(converter=ensure_cls(DataConfig)) + job = attr.ib(converter=ensure_cls(JobConfig)) + cache_size = attr.ib(type=int) + max_iterations = attr.ib(type=int) + checkpoint_stride = attr.ib(type=int) + snapshot_stride = attr.ib(type=int) + profiling_stride = attr.ib(type=int) + use_tf_data = attr.ib(type=bool) + use_auto_mixed_precision = attr.ib(type=bool) + val_log_step = attr.ib(type=int) + + +@attr.s(kw_only=True) +class TrainTrackingConfig(TrainConfig): + # (radius for binary map -> *2) (optional) + parent_radius = attr.ib(type=List[float]) + # (sigma for Gauss -> ~*4 (5 in z -> in 3 slices)) (optional) + rasterize_radius = attr.ib(type=List[float]) + augment = attr.ib(converter=ensure_cls(AugmentTrackingConfig)) + parent_vectors_loss_transition = attr.ib(type=int, default=50000) + use_radius = attr.ib(type=Dict[int, int], + converter=use_radius_converter()) + + + +@attr.s(kw_only=True) +class TrainCellCycleConfig(TrainConfig): + batch_size = attr.ib(type=int) + augment = attr.ib(converter=ensure_cls(AugmentCellCycleConfig)) + use_database = attr.ib(type=bool) + database = attr.ib(converter=ensure_cls(DataDBConfig), default=False) From 52c62a140bc02e42adbaad166c3590bf41dbb65f Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 15 Feb 2021 09:06:18 -0500 Subject: [PATCH 016/263] add cell_cycle/tracking config --- linajea/config/cell_cycle_config.py | 41 +++++++++++++++++++++++++++++ linajea/config/tracking_config.py | 31 ++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 linajea/config/cell_cycle_config.py create mode 100644 linajea/config/tracking_config.py diff --git a/linajea/config/cell_cycle_config.py b/linajea/config/cell_cycle_config.py new file mode 100644 index 0000000..72fad86 --- /dev/null +++ b/linajea/config/cell_cycle_config.py @@ -0,0 +1,41 @@ +import attr + +from .cnn_config import (EfficientNetConfig, + ResNetConfig, + VGGConfig) +from .evaluate import EvaluateConfig +from .general import GeneralConfig +from .optimizer import OptimizerConfig +from .predict import PredictConfig +from .train import TrainCellCycleConfig +from .utils import ensure_cls + + +def model_converter(): + def converter(val): + if val['network_type'].lower() == "vgg": + return VGGConfig(**val) + elif val['network_type'].lower() == "resnet": + return ResNetConfig(**val) + elif val['network_type'].lower() == "efficientnet": + return EfficientNetConfig(**val) + else: + raise RuntimeError("invalid network_type: {}!".format( + val['network_type'])) + return converter + + +@attr.s(kw_only=True) +class CellCycleConfig: + path = attr.ib(type=str) + general = attr.ib(converter=ensure_cls(GeneralConfig)) + model = attr.ib(converter=model_converter()) + optimizer = attr.ib(converter=ensure_cls(OptimizerConfig)) + train = attr.ib(converter=ensure_cls(TrainCellCycleConfig)) + predict = attr.ib(converter=ensure_cls(PredictConfig)) + evaluate = attr.ib(converter=ensure_cls(EvaluateConfig)) + + @classmethod + def from_file(cls, path): + config_dict = load_config(path) + return cls(path, **config_dict) diff --git a/linajea/config/tracking_config.py b/linajea/config/tracking_config.py new file mode 100644 index 0000000..3a04679 --- /dev/null +++ b/linajea/config/tracking_config.py @@ -0,0 +1,31 @@ +import attr + +from linajea import load_config + +from .evaluate import EvaluateConfig +from .extract import ExtractConfig +from .general import GeneralConfig +from .optimizer import OptimizerConfig +from .predict import PredictConfig +from .solve import SolveConfig +from .train import TrainTrackingConfig +from .unet_config import UnetConfig +from .utils import ensure_cls + + +@attr.s +class TrackingConfig: + path = attr.ib(type=str) + general = attr.ib(converter=ensure_cls(GeneralConfig)) + model = attr.ib(converter=ensure_cls(UnetConfig)) + optimizer = attr.ib(converter=ensure_cls(OptimizerConfig)) + train = attr.ib(converter=ensure_cls(TrainTrackingConfig)) + predict = attr.ib(converter=ensure_cls(PredictConfig)) + extract = attr.ib(converter=ensure_cls(ExtractConfig)) + solve = attr.ib(converter=ensure_cls(SolveConfig)) + evaluate = attr.ib(converter=ensure_cls(EvaluateConfig)) + + @classmethod + def from_file(cls, path): + config_dict = load_config(path) + return cls(path, **config_dict) From 3f61f4521b6284117a1987843743a20d24d17010 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 15 Feb 2021 09:06:45 -0500 Subject: [PATCH 017/263] add separate cell_cycle/tracking sample configs --- linajea/config/sample_config_cellcycle.toml | 135 +++++++++++++++++ linajea/config/sample_config_tracking.toml | 159 ++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 linajea/config/sample_config_cellcycle.toml create mode 100644 linajea/config/sample_config_tracking.toml diff --git a/linajea/config/sample_config_cellcycle.toml b/linajea/config/sample_config_cellcycle.toml new file mode 100644 index 0000000..c65b083 --- /dev/null +++ b/linajea/config/sample_config_cellcycle.toml @@ -0,0 +1,135 @@ +[general] +setup = "setup211_simple_train_side_2" +db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin" +db_name = "linajea_120828_setup211_simple_eval_side_1_400000" +sample = "120828" +setups_dir = "../unet_setups" + + +[model] +net_name = "class_net" +network_type = 'efficientnet' +input_shape = [ 3, 8, 64, 64,] +pad_raw = [ 3, 30, 30, 30,] + +# num_fmaps = [8, 16, 32, 64] +# fmap_inc_factors = [ 2, 2, 2, 1,] +# downsample_factors = [ [ 2, 2, 2,], [ 2, 2, 2,], [ 2, 2, 2,], [ 1, 1, 1,],] +# fc_size = 512 +# kernel_sizes = [ [ 3, 3,], [ 3, 3,], [ 3, 3, 3, 3,], [ 3, 3, 3, 3,],] + +efficientnet_size = "B10" + +# resnet_size = '18' +# num_blocks = [3, 4, 4] +# use_bottleneck = true + +activation = "relu" +padding = "SAME" +merge_time_voxel_size = 2 + +use_dropout = false +use_batchnorm = true +use_bias = true +use_global_pool = true + +num_classes = 3 +use_conv4d = true +make_isotropic = true + +[train] +use_database = false +use_auto_mixed_precision = true +use_tf_data = false +val_log_step = 10 +batch_size = 32 +cache_size = 1024 +max_iterations = 2000 +checkpoint_stride = 1000 +snapshot_stride = 250 +profiling_stride = 500 +# regularizer_weight = 0.00000001 + + + +[train.augment] +[train.augment.elastic] +# control_point_spacing = [ 2, 10, 10,] +control_point_spacing = [ 5, 25, 25,] +jitter_sigma = [ 1, 1, 1,] +# jitter_sigma = [ 0.1, 0.1, 0.1,] +# jitter_sigma = [ 2, 2, 2,] +rotation_min = -10 +rotation_max = 10 +subsample = 4 + + +[train.augment.intensity] +scale = [0.8, 1.2] +shift = [-0.005, 0.005] + + +[train.augment.simple] +mirror = [2, 3] +transpose = [2, 3] + +[train.augment.noise_gaussian] +var = [0.2] + +[train.augment.noise_saltpepper] +amount = [0.001] + +[train.augment.jitter] +jitter = [ 0, 1, 5, 5,] + + +# # # if use_database = true: +[train.database] +db_name = "db_name_str" +[train.database.general] +setup_dir = "/path/to/setup" + +[train.database.prediction] +iteration = 400000 +cell_score_threshold = 0.3 + + +[train.data] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group='raw' +roi_offset = [0, 0, 0, 0] +roi_shape = [200, 385, 512, 712] + + +[train.job] +num_workers = 16 +queue = 'gpu_rtx' + + +[optimizer] +# optimizer = "GradientDescentOptimizer" +# optimizer = "MomentumOptimizer" +optimizer = "AdamOptimizer" +[optimizer.args] +learning_rate = 0.0005 +# momentum = 0.9 +[optimizer.kwargs] +beta1 = 0.95 +beta2 = 0.999 +epsilon = 1e-8 + + +[predict] +iteration = 100000 +cell_score_threshold = 0.4 +[predict.data] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group='raw' +[predict.job] +num_workers = 16 +queue = 'gpu_rtx' + +[evaluate] +gt_db_name = 'linajea_120828_gt_side_1' +matching_threshold = 15 +from_scratch = true diff --git a/linajea/config/sample_config_tracking.toml b/linajea/config/sample_config_tracking.toml new file mode 100644 index 0000000..096ff75 --- /dev/null +++ b/linajea/config/sample_config_tracking.toml @@ -0,0 +1,159 @@ +[general] +setup = "setup211_simple_train_side_2" +db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin" +db_name = "linajea_120828_setup211_simple_eval_side_1_400000" +sample = "120828" +setups_dir = "../unet_setups" + +[model] +train_input_shape = [ + 7, + 40, + 148, + 148 + ] +predict_input_shape = [ + 7, + 80, + 260, + 260 + ] +fmap_inc_factors = 3 +downsample_factors = [[1, 2, 2], [1, 2, 2], [2, 2, 2]] +kernel_size_down = [[3, 3], [3, 3], [3, 3], [3, 3]] +kernel_size_up = [[3, 3], [3, 3], [3, 3]] +constant_upsample = false +average_vectors = false +nms_window_shape = [3, 11, 11] +# nms_window_shape = [5, 15, 15] +unet_style = 'multihead' +num_fmaps = 36 +# num_fmaps = [24, 12] +cell_indicator_weighted = true + + +[train] +cache_size = 10 +checkpoint_stride = 10000 +snapshot_stride = 1000 +profiling_stride = 100 +max_iterations = 50000 +use_tf_data = false +use_auto_mixed_precision = false +val_log_step = 10 + +# radius for binary map -> *2 +parent_radius = [0.1, 16.0, 8.0, 8.0] +# sigma for Gauss -> ~*4 (5 in z -> in 3 slices) +rasterize_radius = [0.1, 16.0, 5.0, 5.0] +parent_vectors_loss_transition = 50000 + + +[train.data] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group='raw' +roi_offset = [0, 0, 0, 0] +roi_shape = [200, 175, 512, 712] + +[train.job] +num_workers = 5 +queue = 'gpu_rtx' + + +[train.use_radius] +15 = 30 +60 = 20 +100 = 15 +1000 = 10 + +[train.augment] +reject_empty_prob = 0.99 +# default/None, minmax, mean, median +normalization = 'percminmax' +norm_bounds = [0, 255] +# perc_min = 'perc0_01' +# perc_max = 'perc99_99' +# norm_min = 100 +# norm_max = 5000 +# norm_min = 2000 +# norm_max = 7500 +divisions = true + +[train.augment.elastic] +# control_point_spacing = [5, 25, 25] +control_point_spacing = [5, 25, 25] +jitter_sigma = [1,1,1] +rotation_min = -10 +rotation_max = 10 +subsample = 4 +# use_fast_points_transform = false + +[train.augment.shift] +prob_slip = 0.1 +prob_shift = 0.1 +sigma = [0, 4, 4, 4] + +[train.augment.intensity] +scale = [0.8, 1.2] +shift = [-0.1, 0.1] + +[train.augment.simple] +mirror = [ 2, 3] +transpose = [2, 3] + +[train.augment.noise_gaussian] +var = [0.3] + +[train.augment.noise_saltpepper] +amount = [0.001] + + +[optimizer] +# optimizer = "GradientDescentOptimizer" +# optimizer = "MomentumOptimizer" +optimizer = "AdamOptimizer" +[optimizer.args] +learning_rate = 0.0005 +# momentum = 0.9 +[optimizer.kwargs] +beta1 = 0.95 +beta2 = 0.999 +epsilon = 1e-8 + + +[predict] +iteration = 100000 +cell_score_threshold = 0.4 +[predict.data] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group='raw' +[predict.job] +num_workers = 16 +queue = 'gpu_rtx' + +[extract] +edge_move_threshold = 1 +block_size = [5, 500, 500, 500] +[extract.job] +num_workers = 16 +queue = 'local' + +[solve] +cost_appear = 10000000000.0 +cost_disappear = 100000.0 +cost_split = 0 +threshold_node_score = 0.5 +weight_node_score = 100 +threshold_edge_score = 5 +weight_prediction_distance_cost = 1 +block_size = [ 5, 500, 500, 500,] +context = [ 2, 100, 100, 100,] +from_scratch = false +[solve.job] +num_workers = 8 +queue = 'cpu' + +[evaluate] +gt_db_name = 'linajea_120828_gt_side_1' +matching_threshold = 15 +from_scratch = true From 991cc32b68e2d373bc7ab6525b08481fbc1ef48f Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 15 Feb 2021 09:07:38 -0500 Subject: [PATCH 018/263] add data db config --- linajea/config/data.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/linajea/config/data.py b/linajea/config/data.py index 1d056e7..44b4379 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -1,11 +1,29 @@ import attr from typing import List +from .utils import ensure_cls -@attr.s + +@attr.s(kw_only=True) class DataConfig: filename = attr.ib(type=str) group = attr.ib(type=str, default=None) voxel_size = attr.ib(type=List[int], default=None) roi_offset = attr.ib(type=List[int], default=None) roi_shape = attr.ib(type=List[int], default=None) + + +@attr.s(kw_only=True) +class DataDBConfig: + @attr.s(kw_only=True) + class DataDBMetaGeneralConfig: + setup_dir = attr.ib(type=str) + + @attr.s(kw_only=True) + class DataDBMetaPredictionConfig: + iteration = attr.ib(type=int) + cell_score_threshold = attr.ib(type=float) + + db_name = attr.ib(type=str, default=None) + general = attr.ib(converter=ensure_cls(DataDBMetaGeneralConfig), default=None) + prediction = attr.ib(converter=ensure_cls(DataDBMetaPredictionConfig), default=None) From 75c6bf7968e6b454011b33d00a6a7c90960a1d73 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 15 Feb 2021 09:07:56 -0500 Subject: [PATCH 019/263] update config __init__ and test script --- linajea/config/__init__.py | 16 ++++++++--- linajea/config/config_test.py | 53 +++++++++++++++++++++++++++-------- linajea/config/extract.py | 2 +- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index fa5494e..d514817 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -1,9 +1,17 @@ # flake8: noqa -from .linajea_config import LinajeaConfig -from .general import GeneralConfig + +from .augment import AugmentConfig +from .cell_cycle_config import CellCycleConfig +from .cnn_config import (VGGConfig, + ResNetConfig, + EfficientNetConfig) from .data import DataConfig +from .evaluate import EvaluateConfig +from .extract import ExtractConfig +from .general import GeneralConfig from .job import JobConfig +from .linajea_config import LinajeaConfig from .predict import PredictConfig -from .extract import ExtractConfig from .solve import SolveConfig -from .evaluate import EvaluateConfig +from .tracking_config import TrackingConfig +from .train import TrainConfig diff --git a/linajea/config/config_test.py b/linajea/config/config_test.py index 234551c..ecc7417 100644 --- a/linajea/config/config_test.py +++ b/linajea/config/config_test.py @@ -1,22 +1,33 @@ +import attr + from linajea import load_config from linajea.config import ( - GeneralConfig, - DataConfig, - JobConfig, - PredictConfig, - ExtractConfig, - SolveConfig, - EvaluateConfig, - ) + LinajeaConfig, + TrackingConfig, + CellCycleConfig, + GeneralConfig, + DataConfig, + JobConfig, + PredictConfig, + ExtractConfig, + SolveConfig, + EvaluateConfig, + VGGConfig, + ResNetConfig, + EfficientNetConfig, +) if __name__ == "__main__": - config_dict = load_config("sample_config.toml") + # parts of config + config_dict = load_config("sample_config_tracking.toml") general_config = GeneralConfig(**config_dict['general']) print(general_config) - data_config = DataConfig(**config_dict['data']) + data_config = DataConfig(**config_dict['train']['data']) print(data_config) - job_config = JobConfig(**config_dict['job']) + job_config = JobConfig(**config_dict['train']['job']) print(job_config) + + # tracking parts of config predict_config = PredictConfig(**config_dict['predict']) print(predict_config) extract_config = ExtractConfig(**config_dict['extract']) @@ -25,3 +36,23 @@ print(solve_config) evaluate_config = EvaluateConfig(**config_dict['evaluate']) print(evaluate_config) + + + # # cell cycle parts of config + # config_dict = load_config("sample_config_cellcycle.toml") + # vgg_config = VGGConfig(**config_dict['model']) + # print(evaluate_config) + + + # complete configs + tracking_config = TrackingConfig(path="sample_config_tracking.toml", **config_dict) + print(tracking_config) + + tracking_config = TrackingConfig.from_file("sample_config_tracking.toml") + print(tracking_config) + + config_dict = load_config("sample_config_cellcycle.toml") + cell_cycle_config = CellCycleConfig(path="sample_config_cellcycle.toml", **config_dict) + print(cell_cycle_config) + + print(attr.asdict(cell_cycle_config)) diff --git a/linajea/config/extract.py b/linajea/config/extract.py index d50a873..76741d3 100644 --- a/linajea/config/extract.py +++ b/linajea/config/extract.py @@ -14,7 +14,7 @@ def converter(val): return converter -@attr.s +@attr.s(kw_only=True) class ExtractConfig: edge_move_threshold = attr.ib(type=Dict[int, int], converter=edge_move_converter()) From 1ec2d2eb82720626e8ace4671cac7d023223ab5f Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 15 Feb 2021 09:45:55 -0500 Subject: [PATCH 020/263] add model validators --- linajea/config/cnn_config.py | 32 +++++++++++++++------ linajea/config/general.py | 4 ++- linajea/config/sample_config_cellcycle.toml | 17 +++++------ linajea/config/sample_config_tracking.toml | 2 ++ linajea/config/unet_config.py | 31 ++++++++++++++------ linajea/config/utils.py | 19 ++++++++++++ 6 files changed, 79 insertions(+), 26 deletions(-) diff --git a/linajea/config/cnn_config.py b/linajea/config/cnn_config.py index 0b7f5b9..15af5a8 100644 --- a/linajea/config/cnn_config.py +++ b/linajea/config/cnn_config.py @@ -3,14 +3,21 @@ from .data import DataConfig from .job import JobConfig -from .utils import ensure_cls +from .utils import (ensure_cls, + _check_nd_shape, + _int_list_validator, + _list_int_list_validator) @attr.s(kw_only=True) class CNNConfig: # shape -> voxels, size -> world units - input_shape = attr.ib(type=List[int]) - pad_raw = attr.ib(type=List[int]) + input_shape = attr.ib(type=List[int], + validator=[_int_list_validator, + _check_nd_shape(4)]) + pad_raw = attr.ib(type=List[int], + validator=[_int_list_validator, + _check_nd_shape(4)]) activation = attr.ib(type=str, default='relu') padding = attr.ib(type=str, default='SAME') merge_time_voxel_size = attr.ib(type=int, default=1) @@ -20,16 +27,22 @@ class CNNConfig: use_global_pool = attr.ib(type=bool, default=True) use_conv4d = attr.ib(type=bool, default=True) num_classes = attr.ib(type=int, default=3) - network_type = attr.ib(type=str, default="vgg") + network_type = attr.ib( + type=str, + validator=attr.validators.in_(["vgg", "resnet", "efficientnet"])) make_isotropic = attr.ib(type=int, default=False) @attr.s(kw_only=True) class VGGConfig(CNNConfig): - num_fmaps = attr.ib(type=List[int], default=32) - fmap_inc_factors = attr.ib(type=List[int]) - downsample_factors = attr.ib(type=List[List[int]]) - kernel_sizes = attr.ib(type=List[List[int]]) + num_fmaps = attr.ib(type=List[int], + validator=_int_list_validator) + fmap_inc_factors = attr.ib(type=List[int], + validator=_int_list_validator) + downsample_factors = attr.ib(type=List[List[int]], + validator=_list_int_list_validator) + kernel_sizes = attr.ib(type=List[List[int]], + validator=_list_int_list_validator) fc_size = attr.ib(type=int) net_name = attr.ib(type=str, default="vgg") @@ -38,7 +51,8 @@ class VGGConfig(CNNConfig): class ResNetConfig(CNNConfig): net_name = attr.ib(type=str, default="resnet") resnet_size = attr.ib(type=str, default="18") - num_blocks = attr.ib(default=None, type=List[int]) + num_blocks = attr.ib(default=None, type=List[int], + validator=_int_list_validator) use_bottleneck = attr.ib(default=None, type=bool) diff --git a/linajea/config/general.py b/linajea/config/general.py index 0f7d5a5..1c26120 100644 --- a/linajea/config/general.py +++ b/linajea/config/general.py @@ -1,7 +1,7 @@ import attr -@attr.s +@attr.s(kw_only=True) class GeneralConfig: setup = attr.ib(type=str) setups_dir = attr.ib(type=str) @@ -10,3 +10,5 @@ class GeneralConfig: db_name = attr.ib(type=str, default=None) singularity_image = attr.ib(type=str, default=None) sparse = attr.ib(type=bool, default=True) + seed = attr.ib(type=int) + logging = attr.ib(type=int) diff --git a/linajea/config/sample_config_cellcycle.toml b/linajea/config/sample_config_cellcycle.toml index c65b083..ca7f3c1 100644 --- a/linajea/config/sample_config_cellcycle.toml +++ b/linajea/config/sample_config_cellcycle.toml @@ -1,24 +1,25 @@ [general] +logging = 20 setup = "setup211_simple_train_side_2" db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin" db_name = "linajea_120828_setup211_simple_eval_side_1_400000" sample = "120828" setups_dir = "../unet_setups" - +seed = 42 [model] net_name = "class_net" -network_type = 'efficientnet' +network_type = 'vgg' input_shape = [ 3, 8, 64, 64,] pad_raw = [ 3, 30, 30, 30,] -# num_fmaps = [8, 16, 32, 64] -# fmap_inc_factors = [ 2, 2, 2, 1,] -# downsample_factors = [ [ 2, 2, 2,], [ 2, 2, 2,], [ 2, 2, 2,], [ 1, 1, 1,],] -# fc_size = 512 -# kernel_sizes = [ [ 3, 3,], [ 3, 3,], [ 3, 3, 3, 3,], [ 3, 3, 3, 3,],] +num_fmaps = [8, 16, 32, 64] +fmap_inc_factors = [ 2, 2, 2, 1,] +downsample_factors = [ [ 2, 2, 2,], [ 2, 2, 2,], [ 2, 2, 2,], [ 1, 1, 1,],] +fc_size = 512 +kernel_sizes = [ [ 3, 3,], [ 3, 3,], [ 3, 3, 3, 3,], [ 3, 3, 3, 3,],] -efficientnet_size = "B10" +# efficientnet_size = "B10" # resnet_size = '18' # num_blocks = [3, 4, 4] diff --git a/linajea/config/sample_config_tracking.toml b/linajea/config/sample_config_tracking.toml index 096ff75..b4306b6 100644 --- a/linajea/config/sample_config_tracking.toml +++ b/linajea/config/sample_config_tracking.toml @@ -4,6 +4,8 @@ db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin" db_name = "linajea_120828_setup211_simple_eval_side_1_400000" sample = "120828" setups_dir = "../unet_setups" +logging = 20 +seed = 42 [model] train_input_shape = [ diff --git a/linajea/config/unet_config.py b/linajea/config/unet_config.py index 842a518..a698c62 100644 --- a/linajea/config/unet_config.py +++ b/linajea/config/unet_config.py @@ -3,21 +3,36 @@ from .data import DataConfig from .job import JobConfig -from .utils import ensure_cls +from .utils import (ensure_cls, + _check_nd_shape, + _int_list_validator, + _list_int_list_validator) @attr.s(kw_only=True) class UnetConfig: # shape -> voxels, size -> world units - train_input_shape = attr.ib(type=List[int]) - predict_input_shape = attr.ib(type=List[int]) + train_input_shape = attr.ib(type=List[int], + validator=[_int_list_validator, + _check_nd_shape(4)]) + predict_input_shape = attr.ib(type=List[int], + validator=[_int_list_validator, + _check_nd_shape(4)]) fmap_inc_factors = attr.ib(type=int) - downsample_factors = attr.ib(type=List[List[int]]) - kernel_size_down = attr.ib(type=List[List[int]]) - kernel_size_up = attr.ib(type=List[List[int]]) - upsampling = attr.ib(type=str, default="uniform_transposed_conv") + downsample_factors = attr.ib(type=List[List[int]], + validator=_list_int_list_validator) + kernel_size_down = attr.ib(type=List[List[int]], + validator=_list_int_list_validator) + kernel_size_up = attr.ib(type=List[List[int]], + validator=_list_int_list_validator) + upsampling = attr.ib(type=str, default="uniform_transposed_conv", + validator=attr.validators.in_([ + "transposed_conv", "resize_conv", + "uniform_transposed_conv"])) constant_upsample = attr.ib(type=bool) - nms_window_shape = attr.ib(type=List[int]) + nms_window_shape = attr.ib(type=List[int], + validator=[_int_list_validator, + _check_nd_shape(3)]) average_vectors = attr.ib(type=bool, default=False) unet_style = attr.ib(type=str, default='split') num_fmaps = attr.ib(type=int) diff --git a/linajea/config/utils.py b/linajea/config/utils.py index c8d547c..2d9c779 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -1,3 +1,6 @@ +import attr + + def ensure_cls(cl): """If the attribute is an instance of cls, pass, else try constructing.""" def converter(val): @@ -6,3 +9,19 @@ def converter(val): else: return cl(**val) return converter + + +def _check_nd_shape(ndims): + + def _check_shape(self, attribute, value): + if len(value) != ndims: + raise ValueError("{} must be 4d".format(attribute)) + return _check_shape + +_int_list_validator = attr.validators.deep_iterable( + member_validator=attr.validators.instance_of(int), + iterable_validator=attr.validators.instance_of(list)) + +_list_int_list_validator = attr.validators.deep_iterable( + member_validator=_int_list_validator, + iterable_validator=attr.validators.instance_of(list)) From 794f6ac7a56ac15682c95ce24a7e9169ead13c26 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 16 Feb 2021 08:43:06 -0500 Subject: [PATCH 021/263] config: add validate/test, roi to own class, update database config --- linajea/config/__init__.py | 10 ++- linajea/config/cell_cycle_config.py | 21 +++-- linajea/config/config_test.py | 30 +++---- linajea/config/data.py | 29 +++---- linajea/config/evaluate.py | 22 +++++- linajea/config/predict.py | 15 ++-- linajea/config/sample_config_cellcycle.toml | 86 +++++++++++++++------ linajea/config/sample_config_tracking.toml | 40 ++++++++-- linajea/config/test.py | 19 +++++ linajea/config/tracking_config.py | 18 +++-- linajea/config/train.py | 2 +- linajea/config/validate.py | 19 +++++ 12 files changed, 218 insertions(+), 93 deletions(-) create mode 100644 linajea/config/test.py create mode 100644 linajea/config/validate.py diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index d514817..5ec9ccc 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -11,7 +11,13 @@ from .general import GeneralConfig from .job import JobConfig from .linajea_config import LinajeaConfig -from .predict import PredictConfig +from .predict import (PredictCellCycleConfig, + PredictTrackingConfig) from .solve import SolveConfig from .tracking_config import TrackingConfig -from .train import TrainConfig +from .test import (TestConfig, + TestCellCycleConfig) +from .train import (TrainTrackingConfig, + TrainCellCycleConfig) +from .validate import (ValidateConfig, + ValidateCellCycleConfig) diff --git a/linajea/config/cell_cycle_config.py b/linajea/config/cell_cycle_config.py index 72fad86..bc3602d 100644 --- a/linajea/config/cell_cycle_config.py +++ b/linajea/config/cell_cycle_config.py @@ -1,24 +1,27 @@ import attr +from linajea import load_config from .cnn_config import (EfficientNetConfig, ResNetConfig, VGGConfig) -from .evaluate import EvaluateConfig +from .evaluate import EvaluateCellCycleConfig from .general import GeneralConfig from .optimizer import OptimizerConfig -from .predict import PredictConfig +from .predict import PredictCellCycleConfig +from .test import TestCellCycleConfig from .train import TrainCellCycleConfig from .utils import ensure_cls +from .validate import ValidateCellCycleConfig def model_converter(): def converter(val): if val['network_type'].lower() == "vgg": - return VGGConfig(**val) + return VGGConfig(**val) # type: ignore elif val['network_type'].lower() == "resnet": - return ResNetConfig(**val) + return ResNetConfig(**val) # type: ignore elif val['network_type'].lower() == "efficientnet": - return EfficientNetConfig(**val) + return EfficientNetConfig(**val) # type: ignore else: raise RuntimeError("invalid network_type: {}!".format( val['network_type'])) @@ -32,10 +35,12 @@ class CellCycleConfig: model = attr.ib(converter=model_converter()) optimizer = attr.ib(converter=ensure_cls(OptimizerConfig)) train = attr.ib(converter=ensure_cls(TrainCellCycleConfig)) - predict = attr.ib(converter=ensure_cls(PredictConfig)) - evaluate = attr.ib(converter=ensure_cls(EvaluateConfig)) + test = attr.ib(converter=ensure_cls(TestCellCycleConfig)) + validate = attr.ib(converter=ensure_cls(ValidateCellCycleConfig)) + predict = attr.ib(converter=ensure_cls(PredictCellCycleConfig)) + evaluate = attr.ib(converter=ensure_cls(EvaluateCellCycleConfig)) @classmethod def from_file(cls, path): config_dict = load_config(path) - return cls(path, **config_dict) + return cls(path=path, **config_dict) # type: ignore diff --git a/linajea/config/config_test.py b/linajea/config/config_test.py index ecc7417..0bdcd94 100644 --- a/linajea/config/config_test.py +++ b/linajea/config/config_test.py @@ -2,57 +2,47 @@ from linajea import load_config from linajea.config import ( - LinajeaConfig, TrackingConfig, CellCycleConfig, GeneralConfig, DataConfig, JobConfig, - PredictConfig, + PredictTrackingConfig, ExtractConfig, SolveConfig, EvaluateConfig, - VGGConfig, - ResNetConfig, - EfficientNetConfig, ) if __name__ == "__main__": # parts of config config_dict = load_config("sample_config_tracking.toml") - general_config = GeneralConfig(**config_dict['general']) + general_config = GeneralConfig(**config_dict['general']) # type: ignore print(general_config) - data_config = DataConfig(**config_dict['train']['data']) + data_config = DataConfig(**config_dict['train']['data']) # type: ignore print(data_config) - job_config = JobConfig(**config_dict['train']['job']) + job_config = JobConfig(**config_dict['train']['job']) # type: ignore print(job_config) # tracking parts of config - predict_config = PredictConfig(**config_dict['predict']) + predict_config = PredictTrackingConfig(**config_dict['predict']) # type: ignore print(predict_config) - extract_config = ExtractConfig(**config_dict['extract']) + extract_config = ExtractConfig(**config_dict['extract']) # type: ignore print(extract_config) - solve_config = SolveConfig(**config_dict['solve']) + solve_config = SolveConfig(**config_dict['solve']) # type: ignore print(solve_config) - evaluate_config = EvaluateConfig(**config_dict['evaluate']) + evaluate_config = EvaluateConfig(**config_dict['evaluate']) # type: ignore print(evaluate_config) - # # cell cycle parts of config - # config_dict = load_config("sample_config_cellcycle.toml") - # vgg_config = VGGConfig(**config_dict['model']) - # print(evaluate_config) - - # complete configs - tracking_config = TrackingConfig(path="sample_config_tracking.toml", **config_dict) + tracking_config = TrackingConfig(path="sample_config_tracking.toml", **config_dict) # type: ignore print(tracking_config) tracking_config = TrackingConfig.from_file("sample_config_tracking.toml") print(tracking_config) config_dict = load_config("sample_config_cellcycle.toml") - cell_cycle_config = CellCycleConfig(path="sample_config_cellcycle.toml", **config_dict) + cell_cycle_config = CellCycleConfig(path="sample_config_cellcycle.toml", **config_dict) # type: ignore print(cell_cycle_config) print(attr.asdict(cell_cycle_config)) diff --git a/linajea/config/data.py b/linajea/config/data.py index 44b4379..b9a44e4 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -5,25 +5,28 @@ @attr.s(kw_only=True) -class DataConfig: +class DataFileConfig: filename = attr.ib(type=str) group = attr.ib(type=str, default=None) voxel_size = attr.ib(type=List[int], default=None) - roi_offset = attr.ib(type=List[int], default=None) - roi_shape = attr.ib(type=List[int], default=None) @attr.s(kw_only=True) class DataDBConfig: - @attr.s(kw_only=True) - class DataDBMetaGeneralConfig: - setup_dir = attr.ib(type=str) + db_name = attr.ib(type=str, default=None) + setup_dir = attr.ib(type=str, default=None) + checkpoint = attr.ib(type=int) + cell_score_threshold = attr.ib(type=float) - @attr.s(kw_only=True) - class DataDBMetaPredictionConfig: - iteration = attr.ib(type=int) - cell_score_threshold = attr.ib(type=float) - db_name = attr.ib(type=str, default=None) - general = attr.ib(converter=ensure_cls(DataDBMetaGeneralConfig), default=None) - prediction = attr.ib(converter=ensure_cls(DataDBMetaPredictionConfig), default=None) +@attr.s(kw_only=True) +class DataROIConfig: + offset = attr.ib(type=List[int], default=None) + shape = attr.ib(type=List[int], default=None) + + +@attr.s(kw_only=True) +class DataConfig: + datafile = attr.ib(converter=ensure_cls(DataFileConfig)) + database = attr.ib(converter=ensure_cls(DataDBConfig), default=None) + roi = attr.ib(converter=ensure_cls(DataROIConfig)) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 646684f..a3608d6 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -1,14 +1,30 @@ import attr -from .data import DataConfig +from .data import DataROIConfig from .job import JobConfig from .utils import ensure_cls -@attr.s +@attr.s(kw_only=True) class EvaluateConfig: gt_db_name = attr.ib(type=str) matching_threshold = attr.ib(type=int) - data = attr.ib(converter=ensure_cls(DataConfig), default=None) + roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) job = attr.ib(converter=ensure_cls(JobConfig), default=None) from_scratch = attr.ib(type=bool, default=False) + + +@attr.s(kw_only=True) +class EvaluateTrackingConfig(EvaluateConfig): + from_scratch = attr.ib(type=bool, default=False) + + +@attr.s(kw_only=True) +class EvaluateCellCycleConfig(EvaluateConfig): + max_samples = attr.ib(type=int) + metric = attr.ib(type=str) + use_database = attr.ib(type=bool, default=True) + one_off = attr.ib(type=bool) + prob_threshold = attr.ib(type=float) + dry_run = attr.ib(type=bool) + find_fn = attr.ib(type=bool) diff --git a/linajea/config/predict.py b/linajea/config/predict.py index a05fc5f..b049b93 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -1,15 +1,20 @@ import attr -from .data import DataConfig from .job import JobConfig from .utils import ensure_cls -@attr.s +@attr.s(kw_only=True) class PredictConfig: - data = attr.ib(converter=ensure_cls(DataConfig)) job = attr.ib(converter=ensure_cls(JobConfig)) - iteration = attr.ib(type=int) - cell_score_threshold = attr.ib(type=float) + + +@attr.s(kw_only=True) +class PredictTrackingConfig(PredictConfig): write_to_zarr = attr.ib(type=bool, default=False) write_to_db = attr.ib(type=bool, default=True) processes_per_worker = attr.ib(type=int, default=1) + + +@attr.s(kw_only=True) +class PredictCellCycleConfig(PredictConfig): + batch_size = attr.ib(type=int) diff --git a/linajea/config/sample_config_cellcycle.toml b/linajea/config/sample_config_cellcycle.toml index ca7f3c1..e478cd0 100644 --- a/linajea/config/sample_config_cellcycle.toml +++ b/linajea/config/sample_config_cellcycle.toml @@ -51,9 +51,6 @@ snapshot_stride = 250 profiling_stride = 500 # regularizer_weight = 0.00000001 - - -[train.augment] [train.augment.elastic] # control_point_spacing = [ 2, 10, 10,] control_point_spacing = [ 5, 25, 25,] @@ -64,12 +61,10 @@ rotation_min = -10 rotation_max = 10 subsample = 4 - [train.augment.intensity] scale = [0.8, 1.2] shift = [-0.005, 0.005] - [train.augment.simple] mirror = [2, 3] transpose = [2, 3] @@ -83,25 +78,20 @@ amount = [0.001] [train.augment.jitter] jitter = [ 0, 1, 5, 5,] - -# # # if use_database = true: -[train.database] +[train.data.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] +[train.data.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group='raw' +# if use_database = true: +[train.data.database] db_name = "db_name_str" -[train.database.general] +# or setup_dir = "/path/to/setup" - -[train.database.prediction] -iteration = 400000 +checkpoint = 400000 cell_score_threshold = 0.3 - -[train.data] -filename = "/nrs/funke/malinmayorc/120828/120828.n5" -group='raw' -roi_offset = [0, 0, 0, 0] -roi_shape = [200, 385, 512, 712] - - [train.job] num_workers = 16 queue = 'gpu_rtx' @@ -120,17 +110,63 @@ beta2 = 0.999 epsilon = 1e-8 -[predict] -iteration = 100000 -cell_score_threshold = 0.4 -[predict.data] +[test] +use_database = false +checkpoint = 10000 +cell_score_threshold = 0.3 +[test.data.datafile] filename = "/nrs/funke/malinmayorc/120828/120828.n5" group='raw' +# if use_database = true: +[test.data.database] +db_name = "db_name_str" +# or +setup_dir = "/path/to/setup" +checkpoint = 400000 +cell_score_threshold = 0.3 +[test.data.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] + + +[validate] +use_database = false +cell_score_threshold = [ 0.1, 0.5, 0.75, 0.9, 0.99,] +checkpoints = [ 2000, 5000, 10000] +[validate.data.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group='raw' +# if use_database = true: +[validate.data.database] +db_name = "db_name_str" +# or +setup_dir = "/path/to/setup" +checkpoint = 400000 +cell_score_threshold = 0.3 +[validate.data.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] + + +[predict] +batch_size = 64 [predict.job] num_workers = 16 queue = 'gpu_rtx' [evaluate] +max_samples = 200000 +metric = "AP" +use_database = false +one_off = false +# distance_limit = 11 +prob_threshold = 0.99 +dry_run = true +find_fn = false gt_db_name = 'linajea_120828_gt_side_1' matching_threshold = 15 -from_scratch = true +# optional, can overwrite validate/test roi +[evaluate.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] + diff --git a/linajea/config/sample_config_tracking.toml b/linajea/config/sample_config_tracking.toml index b4306b6..87a6c45 100644 --- a/linajea/config/sample_config_tracking.toml +++ b/linajea/config/sample_config_tracking.toml @@ -50,18 +50,17 @@ parent_radius = [0.1, 16.0, 8.0, 8.0] rasterize_radius = [0.1, 16.0, 5.0, 5.0] parent_vectors_loss_transition = 50000 - -[train.data] +[train.data.datafile] filename = "/nrs/funke/malinmayorc/120828/120828.n5" group='raw' -roi_offset = [0, 0, 0, 0] -roi_shape = [200, 175, 512, 712] +[train.data.roi] +offset = [0, 0, 0, 0] +shape = [200, 175, 512, 712] [train.job] num_workers = 5 queue = 'gpu_rtx' - [train.use_radius] 15 = 30 60 = 20 @@ -123,12 +122,33 @@ beta2 = 0.999 epsilon = 1e-8 -[predict] -iteration = 100000 +[test] +checkpoint = 100000 cell_score_threshold = 0.4 -[predict.data] +[test.data.datafile] filename = "/nrs/funke/malinmayorc/120828/120828.n5" group='raw' +[test.data.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] + + +[validate] +checkpoints = [100000, 200000, 300000, 400000] +cell_score_threshold = 0.3 +[validate.data.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group='raw' +[validate.data.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] + + +[predict] +write_to_zarr = false +write_to_db = true +processes_per_worker = 4 + [predict.job] num_workers = 16 queue = 'gpu_rtx' @@ -159,3 +179,7 @@ queue = 'cpu' gt_db_name = 'linajea_120828_gt_side_1' matching_threshold = 15 from_scratch = true +# optional, can overwrite validate/test roi +[evaluate.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] diff --git a/linajea/config/test.py b/linajea/config/test.py new file mode 100644 index 0000000..d199fe9 --- /dev/null +++ b/linajea/config/test.py @@ -0,0 +1,19 @@ +import attr +from typing import Dict, List + +from .data import (DataConfig, + DataDBConfig) +from .utils import ensure_cls + + +@attr.s(kw_only=True) +class TestConfig: + data = attr.ib(converter=ensure_cls(DataConfig)) + checkpoint = attr.ib(int) + cell_score_threshold = attr.ib(float) + + +@attr.s(kw_only=True) +class TestCellCycleConfig (TestConfig): + use_database = attr.ib(type=bool) + database = attr.ib(converter=ensure_cls(DataDBConfig), default=None) diff --git a/linajea/config/tracking_config.py b/linajea/config/tracking_config.py index 3a04679..7725048 100644 --- a/linajea/config/tracking_config.py +++ b/linajea/config/tracking_config.py @@ -1,31 +1,33 @@ import attr from linajea import load_config - -from .evaluate import EvaluateConfig +from .evaluate import EvaluateTrackingConfig from .extract import ExtractConfig from .general import GeneralConfig from .optimizer import OptimizerConfig -from .predict import PredictConfig +from .predict import PredictTrackingConfig from .solve import SolveConfig +from .test import TestConfig from .train import TrainTrackingConfig from .unet_config import UnetConfig from .utils import ensure_cls +from .validate import ValidateConfig - -@attr.s +@attr.s(kw_only=True) class TrackingConfig: path = attr.ib(type=str) general = attr.ib(converter=ensure_cls(GeneralConfig)) model = attr.ib(converter=ensure_cls(UnetConfig)) optimizer = attr.ib(converter=ensure_cls(OptimizerConfig)) train = attr.ib(converter=ensure_cls(TrainTrackingConfig)) - predict = attr.ib(converter=ensure_cls(PredictConfig)) + test = attr.ib(converter=ensure_cls(TestConfig)) + validate = attr.ib(converter=ensure_cls(ValidateConfig)) + predict = attr.ib(converter=ensure_cls(PredictTrackingConfig)) extract = attr.ib(converter=ensure_cls(ExtractConfig)) solve = attr.ib(converter=ensure_cls(SolveConfig)) - evaluate = attr.ib(converter=ensure_cls(EvaluateConfig)) + evaluate = attr.ib(converter=ensure_cls(EvaluateTrackingConfig)) @classmethod def from_file(cls, path): config_dict = load_config(path) - return cls(path, **config_dict) + return cls(path=path, **config_dict) # type: ignore diff --git a/linajea/config/train.py b/linajea/config/train.py index 996d9b5..065bffe 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -50,4 +50,4 @@ class TrainCellCycleConfig(TrainConfig): batch_size = attr.ib(type=int) augment = attr.ib(converter=ensure_cls(AugmentCellCycleConfig)) use_database = attr.ib(type=bool) - database = attr.ib(converter=ensure_cls(DataDBConfig), default=False) + database = attr.ib(converter=ensure_cls(DataDBConfig), default=None) diff --git a/linajea/config/validate.py b/linajea/config/validate.py new file mode 100644 index 0000000..c165124 --- /dev/null +++ b/linajea/config/validate.py @@ -0,0 +1,19 @@ +import attr +from typing import Dict, List + +from .data import (DataConfig, + DataDBConfig) +from .utils import ensure_cls + + +@attr.s(kw_only=True) +class ValidateConfig: + data = attr.ib(converter=ensure_cls(DataConfig)) + checkpoints = attr.ib(type=List[int]) + cell_score_threshold = attr.ib(type=List[float]) + + +@attr.s(kw_only=True) +class ValidateCellCycleConfig (ValidateConfig): + use_database = attr.ib(type=bool) + database = attr.ib(converter=ensure_cls(DataDBConfig), default=None) From 4caeddb6e0eb8f9094aca9030dfa00d7a976bf36 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 17 Feb 2021 12:37:25 -0500 Subject: [PATCH 022/263] Remove master data config --- linajea/config/data.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/linajea/config/data.py b/linajea/config/data.py index b9a44e4..a7650b9 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -4,11 +4,18 @@ from .utils import ensure_cls +@attr.s(kw_only=True) +class DataROIConfig: + offset = attr.ib(type=List[int], default=None) + shape = attr.ib(type=List[int], default=None) + + @attr.s(kw_only=True) class DataFileConfig: filename = attr.ib(type=str) group = attr.ib(type=str, default=None) voxel_size = attr.ib(type=List[int], default=None) + roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) @attr.s(kw_only=True) @@ -17,16 +24,4 @@ class DataDBConfig: setup_dir = attr.ib(type=str, default=None) checkpoint = attr.ib(type=int) cell_score_threshold = attr.ib(type=float) - - -@attr.s(kw_only=True) -class DataROIConfig: - offset = attr.ib(type=List[int], default=None) - shape = attr.ib(type=List[int], default=None) - - -@attr.s(kw_only=True) -class DataConfig: - datafile = attr.ib(converter=ensure_cls(DataFileConfig)) - database = attr.ib(converter=ensure_cls(DataDBConfig), default=None) - roi = attr.ib(converter=ensure_cls(DataROIConfig)) + roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) From 7dfe107294464889c60dadc7a0f19c1e184e907b Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 17 Feb 2021 12:37:55 -0500 Subject: [PATCH 023/263] Use setup_dir, provide path to predict script --- linajea/config/general.py | 3 ++- linajea/config/predict.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/linajea/config/general.py b/linajea/config/general.py index 1c26120..4d9ec03 100644 --- a/linajea/config/general.py +++ b/linajea/config/general.py @@ -4,7 +4,8 @@ @attr.s(kw_only=True) class GeneralConfig: setup = attr.ib(type=str) - setups_dir = attr.ib(type=str) + # TODO: use post_init to set setup = basename(setup_dir)? + setup_dir = attr.ib(type=str) db_host = attr.ib(type=str) sample = attr.ib(type=str) db_name = attr.ib(type=str, default=None) diff --git a/linajea/config/predict.py b/linajea/config/predict.py index b049b93..4c5db3c 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -10,6 +10,7 @@ class PredictConfig: @attr.s(kw_only=True) class PredictTrackingConfig(PredictConfig): + path_to_script = attr.ib(type=str) write_to_zarr = attr.ib(type=bool, default=False) write_to_db = attr.ib(type=bool, default=True) processes_per_worker = attr.ib(type=int, default=1) From ac5716b7e960dec6bf9e7103f4eaddfea484369c Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 17 Feb 2021 12:38:29 -0500 Subject: [PATCH 024/263] Specify what kind of data in train and validate --- linajea/config/train.py | 5 ++--- linajea/config/validate.py | 5 +++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/linajea/config/train.py b/linajea/config/train.py index 065bffe..e4d1687 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -3,7 +3,7 @@ from .augment import (AugmentTrackingConfig, AugmentCellCycleConfig) -from .data import (DataConfig, +from .data import (DataFileConfig, DataDBConfig) from .job import JobConfig from .utils import ensure_cls @@ -20,7 +20,7 @@ def converter(val): @attr.s(kw_only=True) class TrainConfig: - data = attr.ib(converter=ensure_cls(DataConfig)) + data = attr.ib(converter=ensure_cls(DataFileConfig)) job = attr.ib(converter=ensure_cls(JobConfig)) cache_size = attr.ib(type=int) max_iterations = attr.ib(type=int) @@ -44,7 +44,6 @@ class TrainTrackingConfig(TrainConfig): converter=use_radius_converter()) - @attr.s(kw_only=True) class TrainCellCycleConfig(TrainConfig): batch_size = attr.ib(type=int) diff --git a/linajea/config/validate.py b/linajea/config/validate.py index c165124..4207884 100644 --- a/linajea/config/validate.py +++ b/linajea/config/validate.py @@ -1,14 +1,15 @@ import attr from typing import Dict, List -from .data import (DataConfig, +from .data import (DataFileConfig, DataDBConfig) from .utils import ensure_cls @attr.s(kw_only=True) class ValidateConfig: - data = attr.ib(converter=ensure_cls(DataConfig)) + data = attr.ib(converter=ensure_cls(DataFileConfig)) + database = attr.ib(converter=ensure_cls(DataDBConfig)) checkpoints = attr.ib(type=List[int]) cell_score_threshold = attr.ib(type=List[float]) From 66632a4b4ca93253f4f3e4b3fdc5cb043c808b70 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 17 Feb 2021 12:39:10 -0500 Subject: [PATCH 025/263] Update process blockwise to use new configs --- .../process_blockwise/predict_blockwise.py | 123 ++++++------------ 1 file changed, 42 insertions(+), 81 deletions(-) diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index f1b416f..d2ca17e 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -9,57 +9,29 @@ from funlib.run import run from .daisy_check_functions import check_function -from linajea import load_config from ..datasets import get_source_roi logger = logging.getLogger(__name__) def predict_blockwise( - config_file, - iteration - ): - config = { - "solve_context": daisy.Coordinate((2, 100, 100, 100)), - "num_workers": 16, - "data_dir": '../01_data', - "setups_dir": '../02_setups', - } - master_config = load_config(config_file) - config.update(master_config['general']) - config.update(master_config['predict']) - sample = config['sample'] - data_dir = config['data_dir'] - setup = config['setup'] - # solve_context = daisy.Coordinate(master_config['solve']['context']) - setup_dir = os.path.abspath( - os.path.join(config['setups_dir'], setup)) - voxel_size, source_roi = get_source_roi(data_dir, sample) - predict_roi = source_roi - - # limit to specific frames, if given - if 'limit_to_roi_offset' in config or 'frames' in config: - if 'frames' in config: - frames = config['frames'] - logger.info("Limiting prediction to frames %s" % str(frames)) - begin, end = frames - frames_roi = daisy.Roi( - (begin, None, None, None), - (end - begin, None, None, None)) - predict_roi = predict_roi.intersect(frames_roi) - if 'limit_to_roi_offset' in config: - assert 'limit_to_roi_shape' in config,\ - "Must specify shape and offset in config file" - limit_to_roi = daisy.Roi( - daisy.Coordinate(config['limit_to_roi_offset']), - daisy.Coordinate(config['limit_to_roi_shape'])) - predict_roi = predict_roi.intersect(limit_to_roi) - - # Given frames and rois are the prediction region, - # not the solution region - # predict_roi = target_roi.grow(solve_context, solve_context) - # predict_roi = predict_roi.intersect(source_roi) - + linajea_config, validate=False): + # if validate is true, read validation data from config, else read test + setup_dir = linajea_config.general.setup_dir + if validate: + data = linajea_config.validate.data + database = linajea_config.validate.database + checkpoint = linajea_config.validate.checkpoint + # TODO: What if there are multiple data sources? + else: + data = linajea_config.test.data + database = linajea_config.test.database + checkpoint = linajea_config.test.checkpoint + voxel_size = data.voxel_size + predict_roi = data.roi + if voxel_size is None: + # TODO: get from data zarr/n5, or do in post_init of config + pass # get context and total input and output ROI with open(os.path.join(setup_dir, 'test_net_config.json'), 'r') as f: net_config = json.load(f) @@ -76,8 +48,8 @@ def predict_blockwise( output_roi = predict_roi # prepare output zarr, if necessary - if 'output_zarr' in config: - output_zarr = config['output_zarr'] + if linajea_config.predict.write_to_zarr: + output_zarr = construct_zarr_filename(linajea_config) parent_vectors_ds = 'volumes/parent_vectors' cell_indicator_ds = 'volumes/cell_indicator' output_path = os.path.join(setup_dir, output_zarr) @@ -103,7 +75,7 @@ def predict_blockwise( block_write_roi = daisy.Roi((0, 0, 0, 0), net_output_size) block_read_roi = block_write_roi.grow(context, context) - logger.info("Following ROIs in world units:") + logger.info("Following ROIs in world units:") logger.info("Input ROI = %s" % input_roi) logger.info("Block read ROI = %s" % block_read_roi) logger.info("Block write ROI = %s" % block_write_roi) @@ -112,20 +84,21 @@ def predict_blockwise( logger.info("Starting block-wise processing...") # process block-wise - if 'db_name' in config: + if linajea_config.predict.write_to_db: daisy.run_blockwise( input_roi, block_read_roi, block_write_roi, process_function=lambda: predict_worker( - config_file, - iteration), + linajea_config.general.path, + checkpoint, + data.filename), check_function=lambda b: check_function( b, 'predict', - config['db_name'], - config['db_host']), - num_workers=config['num_workers'], + database.db_name, + linajea_config.general.db_host), + num_workers=linajea_config.predict.job.num_workers, read_write_conflict=False, max_retries=0, fit='valid') @@ -135,52 +108,40 @@ def predict_blockwise( block_read_roi, block_write_roi, process_function=lambda: predict_worker( - config_file, - iteration), - num_workers=config['num_workers'], + linajea_config.general.path, + checkpoint, + data.filename), + num_workers=linajea_config.predict.job.num_workers, read_write_conflict=False, max_retries=0, fit='valid') -def predict_worker( - config_file, - iteration): - config = { - "singularity_image": 'linajea/linajea:v1.1', - "queue": 'slowpoke', - 'setups_dir': '../02_setups' - } - master_config = load_config(config_file) - config.update(master_config['general']) - config.update(master_config['predict']) - singularity_image = config['singularity_image'] - queue = config['queue'] - setups_dir = config['setups_dir'] - setup = config['setup'] - chargeback = config['lab'] +def predict_worker(linajea_config, checkpoint, datafile): worker_id = daisy.Context.from_env().worker_id worker_time = time.time() - if singularity_image is not None: + job = linajea_config.predict.job + if job.singularity_image is not None: image_path = '/nrs/funke/singularity/' - image = image_path + singularity_image + '.img' + image = image_path + job.singularity_image + '.img' logger.debug("Using singularity image %s" % image) else: image = None cmd = run( - command='python -u %s --config %s --iteration %d' % ( - os.path.join(setups_dir, 'predict.py'), - config_file, - iteration), - queue=queue, + command='python -u %s --config %s --iteration %d --sample %s' % ( + linajea_config.predict.path_to_script, + linajea_config.general.path, + checkpoint, + datafile), + queue=job.queue, num_gpus=1, num_cpus=5, singularity_image=image, mount_dirs=['/groups', '/nrs'], execute=False, expand=False, - flags=['-P ' + chargeback] + flags=['-P ' + job.lab] ) logger.info("Starting predict worker...") logger.info("Command: %s" % str(cmd)) From 806c590d48046196e30729761ef7ff3a37a4fe4a Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 23 Feb 2021 10:40:48 -0500 Subject: [PATCH 026/263] Add load_config to config/utils --- linajea/config/utils.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/linajea/config/utils.py b/linajea/config/utils.py index 2d9c779..09f4ae4 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -1,4 +1,29 @@ import attr +import os +import json +import toml + + +def load_config(config_file): + ext = os.path.splitext(config_file)[1] + with open(config_file, 'r') as f: + if ext == '.json': + config = json.load(f) + elif ext == '.toml': + config = toml.load(f) + elif ext == '': + try: + config = toml.load(f) + except ValueError: + try: + config = json.load(f) + except ValueError: + raise ValueError("No file extension provided " + "and cannot be loaded with json or toml") + else: + raise ValueError("Only json and toml config files supported," + " not %s" % ext) + return config def ensure_cls(cl): From f615da853bb4e18d2a4fd4a5460dc0ebca833991 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Thu, 25 Feb 2021 06:28:39 -0500 Subject: [PATCH 027/263] update config: data handling --- linajea/config/cell_cycle_config.py | 10 ++- linajea/config/cnn_config.py | 1 - linajea/config/config_test.py | 12 +-- linajea/config/data.py | 35 ++++++-- linajea/config/linajea_config.py | 15 ---- linajea/config/sample_config_cellcycle.toml | 97 ++++++++++++--------- linajea/config/sample_config_tracking.toml | 71 +++++++++++---- linajea/config/test.py | 19 ---- linajea/config/tracking_config.py | 12 ++- linajea/config/train.py | 8 +- linajea/config/unet_config.py | 1 - linajea/config/utils.py | 15 ++++ linajea/config/validate.py | 20 ----- 13 files changed, 178 insertions(+), 138 deletions(-) delete mode 100644 linajea/config/linajea_config.py delete mode 100644 linajea/config/test.py delete mode 100644 linajea/config/validate.py diff --git a/linajea/config/cell_cycle_config.py b/linajea/config/cell_cycle_config.py index bc3602d..6e81d9e 100644 --- a/linajea/config/cell_cycle_config.py +++ b/linajea/config/cell_cycle_config.py @@ -8,10 +8,11 @@ from .general import GeneralConfig from .optimizer import OptimizerConfig from .predict import PredictCellCycleConfig -from .test import TestCellCycleConfig +from .train_test_validate_data import (TestDataCellCycleConfig, + TrainDataCellCycleConfig, + ValidateDataCellCycleConfig) from .train import TrainCellCycleConfig from .utils import ensure_cls -from .validate import ValidateCellCycleConfig def model_converter(): @@ -35,8 +36,9 @@ class CellCycleConfig: model = attr.ib(converter=model_converter()) optimizer = attr.ib(converter=ensure_cls(OptimizerConfig)) train = attr.ib(converter=ensure_cls(TrainCellCycleConfig)) - test = attr.ib(converter=ensure_cls(TestCellCycleConfig)) - validate = attr.ib(converter=ensure_cls(ValidateCellCycleConfig)) + train_data = attr.ib(converter=ensure_cls(TrainDataCellCycleConfig)) + test_data = attr.ib(converter=ensure_cls(TestDataCellCycleConfig)) + validate_data = attr.ib(converter=ensure_cls(ValidateDataCellCycleConfig)) predict = attr.ib(converter=ensure_cls(PredictCellCycleConfig)) evaluate = attr.ib(converter=ensure_cls(EvaluateCellCycleConfig)) diff --git a/linajea/config/cnn_config.py b/linajea/config/cnn_config.py index 15af5a8..db492d2 100644 --- a/linajea/config/cnn_config.py +++ b/linajea/config/cnn_config.py @@ -1,7 +1,6 @@ import attr from typing import List -from .data import DataConfig from .job import JobConfig from .utils import (ensure_cls, _check_nd_shape, diff --git a/linajea/config/config_test.py b/linajea/config/config_test.py index 0bdcd94..10940ad 100644 --- a/linajea/config/config_test.py +++ b/linajea/config/config_test.py @@ -5,7 +5,7 @@ TrackingConfig, CellCycleConfig, GeneralConfig, - DataConfig, + DataFileConfig, JobConfig, PredictTrackingConfig, ExtractConfig, @@ -18,8 +18,8 @@ config_dict = load_config("sample_config_tracking.toml") general_config = GeneralConfig(**config_dict['general']) # type: ignore print(general_config) - data_config = DataConfig(**config_dict['train']['data']) # type: ignore - print(data_config) + # data_config = DataFileConfig(**config_dict['train']['data']) # type: ignore + # print(data_config) job_config = JobConfig(**config_dict['train']['job']) # type: ignore print(job_config) @@ -41,8 +41,8 @@ tracking_config = TrackingConfig.from_file("sample_config_tracking.toml") print(tracking_config) - config_dict = load_config("sample_config_cellcycle.toml") - cell_cycle_config = CellCycleConfig(path="sample_config_cellcycle.toml", **config_dict) # type: ignore + # config_dict = load_config("sample_config_cellcycle.toml") + cell_cycle_config = CellCycleConfig.from_file("sample_config_cellcycle.toml") # type: ignore print(cell_cycle_config) - print(attr.asdict(cell_cycle_config)) + # print(attr.asdict(cell_cycle_config)) diff --git a/linajea/config/data.py b/linajea/config/data.py index a7650b9..0baec00 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -1,6 +1,9 @@ -import attr +import os from typing import List +import attr + +from linajea import load_config from .utils import ensure_cls @@ -14,14 +17,36 @@ class DataROIConfig: class DataFileConfig: filename = attr.ib(type=str) group = attr.ib(type=str, default=None) - voxel_size = attr.ib(type=List[int], default=None) - roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + file_roi = attr.ib(default=None) + file_voxel_size = attr.ib(default=None) + + def __attrs_post_init__(self): + if os.path.splitext(self.filename)[1] in (".n5", ".zarr"): + # TODO: use daisy to load info from file + self.file_roi = DataROIConfig(offset=[0, 0, 0, 0], + shape=[250, 500, 500, 500]) + self.file_voxel_size = [1, 5, 1, 1] + else: + data_config = load_config(os.path.join(self.filename, + "data_config.toml")) + self.file_voxel_size = data_config['general']['voxel_size'] + self.file_roi = DataROIConfig( + offset=data_config['general']['shape'], + shape=data_config['general']['shape']) + if self.group is None: + self.group = data_config['general']['group'] @attr.s(kw_only=True) -class DataDBConfig: - db_name = attr.ib(type=str, default=None) +class DataDBMetaConfig: setup_dir = attr.ib(type=str, default=None) checkpoint = attr.ib(type=int) cell_score_threshold = attr.ib(type=float) + + +@attr.s(kw_only=True) +class DataConfig: + datafile = attr.ib(converter=ensure_cls(DataFileConfig)) + db_name = attr.ib(type=str, default=None) + voxel_size = attr.ib(type=List[int], default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) diff --git a/linajea/config/linajea_config.py b/linajea/config/linajea_config.py deleted file mode 100644 index ee6ab14..0000000 --- a/linajea/config/linajea_config.py +++ /dev/null @@ -1,15 +0,0 @@ -from .general import GeneralConfig -from .predict import PredictConfig -from .extract import ExtractConfig -from .evaluate import EvaluateConfig -from linajea import load_config - - -class LinajeaConfig: - - def __init__(self, config_file): - config_dict = load_config(config_file) - self.general = GeneralConfig(**config_dict['general']) - self.predict = PredictConfig(**config_dict['predict']) - self.extract = ExtractConfig(**config_dict['extract_edges']) - self.evaluate = EvaluateConfig(**config_dict['evaluate']) diff --git a/linajea/config/sample_config_cellcycle.toml b/linajea/config/sample_config_cellcycle.toml index e478cd0..8da1c95 100644 --- a/linajea/config/sample_config_cellcycle.toml +++ b/linajea/config/sample_config_cellcycle.toml @@ -1,10 +1,10 @@ [general] logging = 20 -setup = "setup211_simple_train_side_2" +# setup = "setup211_simple_train_side_2" db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin" db_name = "linajea_120828_setup211_simple_eval_side_1_400000" sample = "120828" -setups_dir = "../unet_setups" +setup_dir = "../unet_setups/setup211_simple_train_side_2" seed = 42 [model] @@ -39,7 +39,6 @@ use_conv4d = true make_isotropic = true [train] -use_database = false use_auto_mixed_precision = true use_tf_data = false val_log_step = 10 @@ -78,20 +77,6 @@ amount = [0.001] [train.augment.jitter] jitter = [ 0, 1, 5, 5,] -[train.data.roi] -offset = [0, 0, 0, 0] -shape = [200, 385, 512, 712] -[train.data.datafile] -filename = "/nrs/funke/malinmayorc/120828/120828.n5" -group='raw' -# if use_database = true: -[train.data.database] -db_name = "db_name_str" -# or -setup_dir = "/path/to/setup" -checkpoint = 400000 -cell_score_threshold = 0.3 - [train.job] num_workers = 16 queue = 'gpu_rtx' @@ -110,42 +95,72 @@ beta2 = 0.999 epsilon = 1e-8 -[test] +[train_data] use_database = false -checkpoint = 10000 -cell_score_threshold = 0.3 -[test.data.datafile] -filename = "/nrs/funke/malinmayorc/120828/120828.n5" -group='raw' +[train_data.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] # if use_database = true: -[test.data.database] -db_name = "db_name_str" -# or -setup_dir = "/path/to/setup" +[train_data.db_meta_info] +setup_dir = "some_setup_name" checkpoint = 400000 cell_score_threshold = 0.3 -[test.data.roi] -offset = [0, 0, 0, 0] -shape = [200, 385, 512, 712] +# or data_source specific db_name +[[train_data.data_sources]] +# db_name = "db_name_str_A" +[train_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828_a.n5" +group='raw' +[[train_data.data_sources]] +# db_name = "db_name_str_B" +[train_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828_B.n5" +group='raw' -[validate] +[test_data] use_database = false -cell_score_threshold = [ 0.1, 0.5, 0.75, 0.9, 0.99,] -checkpoints = [ 2000, 5000, 10000] -[validate.data.datafile] -filename = "/nrs/funke/malinmayorc/120828/120828.n5" -group='raw' +checkpoint = 10000 +prob_threshold = 0.5 +[test_data.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] # if use_database = true: -[validate.data.database] -db_name = "db_name_str" -# or -setup_dir = "/path/to/setup" +[test_data.db_meta_info] checkpoint = 400000 cell_score_threshold = 0.3 -[validate.data.roi] +setup_dir = "some_setup_name" +# or data_source specific db_name +[[test_data.data_sources]] +# db_name = "db_name_str" +[test_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group='raw' + + +[validate_data] +use_database = false +prob_thresholds = [ 0.1, 0.5, 0.75, 0.9, 0.99,] +checkpoints = [ 2000, 5000, 10000] +[validate_data.roi] offset = [0, 0, 0, 0] shape = [200, 385, 512, 712] +# if use_database = true: +# [validate_data.db_meta_info] +# checkpoint = 400000 +# cell_score_threshold = 0.3 +# setup = "some_setup_name" +# or data_source specific db_name +[[validate_data.data_sources]] +db_name = "db_name_strA" +[validate_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828_A.n5" +group='raw' +[[validate_data.data_sources]] +db_name = "db_name_strB" +[validate_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828_B.n5" +group='raw' [predict] diff --git a/linajea/config/sample_config_tracking.toml b/linajea/config/sample_config_tracking.toml index 87a6c45..5f6f750 100644 --- a/linajea/config/sample_config_tracking.toml +++ b/linajea/config/sample_config_tracking.toml @@ -50,13 +50,6 @@ parent_radius = [0.1, 16.0, 8.0, 8.0] rasterize_radius = [0.1, 16.0, 5.0, 5.0] parent_vectors_loss_transition = 50000 -[train.data.datafile] -filename = "/nrs/funke/malinmayorc/120828/120828.n5" -group='raw' -[train.data.roi] -offset = [0, 0, 0, 0] -shape = [200, 175, 512, 712] - [train.job] num_workers = 5 queue = 'gpu_rtx' @@ -109,6 +102,22 @@ var = [0.3] amount = [0.001] +[train_data] +voxel_size = [1, 5, 1, 1] +[train_data.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] +[[train_data.data_sources]] # this line is necessary for the [[ ]] syntax +[train_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/FILE_A.n5" +group='raw' + +[[train_data.data_sources]] +[train_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/FILE_B.n5" +group='raw' + + [optimizer] # optimizer = "GradientDescentOptimizer" # optimizer = "MomentumOptimizer" @@ -122,27 +131,55 @@ beta2 = 0.999 epsilon = 1e-8 -[test] +[test_data] checkpoint = 100000 cell_score_threshold = 0.4 -[test.data.datafile] -filename = "/nrs/funke/malinmayorc/120828/120828.n5" +voxel_size = [1, 5, 1, 1] +[test_data.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] + +[[test_data.data_sources]] +voxel_size = [1, 5, 1, 1] +db_name = "linajea_some_db_name_test_A" +[test_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/FILE_A.n5" +group='raw' +[test_data.data_sources.roi] +offset = [0, 0, 0, 0] +shape = [200, 385, 512, 712] + +[[test_data.data_sources]] +voxel_size = [1, 5, 1, 1] +db_name = "linajea_some_db_name_test_B" +[test_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/FILE_B.n5" group='raw' -[test.data.roi] +[test_data.data_sources.roi] offset = [0, 0, 0, 0] shape = [200, 385, 512, 712] -[validate] +[validate_data] checkpoints = [100000, 200000, 300000, 400000] -cell_score_threshold = 0.3 -[validate.data.datafile] -filename = "/nrs/funke/malinmayorc/120828/120828.n5" -group='raw' -[validate.data.roi] +cell_score_threshold = 0.4 +voxel_size = [1, 5, 1, 1] +[validate_data.roi] offset = [0, 0, 0, 0] shape = [200, 385, 512, 712] +[[validate_data.data_sources]] +db_name = "linajea_some_db_name_A" +[validate_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/FILE_A.n5" +group='raw' + +[[validate_data.data_sources]] +db_name = "linajea_some_db_name_B" +[validate_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/FILE_B.n5" +group='raw' + [predict] write_to_zarr = false diff --git a/linajea/config/test.py b/linajea/config/test.py deleted file mode 100644 index d199fe9..0000000 --- a/linajea/config/test.py +++ /dev/null @@ -1,19 +0,0 @@ -import attr -from typing import Dict, List - -from .data import (DataConfig, - DataDBConfig) -from .utils import ensure_cls - - -@attr.s(kw_only=True) -class TestConfig: - data = attr.ib(converter=ensure_cls(DataConfig)) - checkpoint = attr.ib(int) - cell_score_threshold = attr.ib(float) - - -@attr.s(kw_only=True) -class TestCellCycleConfig (TestConfig): - use_database = attr.ib(type=bool) - database = attr.ib(converter=ensure_cls(DataDBConfig), default=None) diff --git a/linajea/config/tracking_config.py b/linajea/config/tracking_config.py index 7725048..e6bba99 100644 --- a/linajea/config/tracking_config.py +++ b/linajea/config/tracking_config.py @@ -7,11 +7,14 @@ from .optimizer import OptimizerConfig from .predict import PredictTrackingConfig from .solve import SolveConfig -from .test import TestConfig +# from .test import TestTrackingConfig +from .train_test_validate_data import (TestDataTrackingConfig, + TrainDataTrackingConfig, + ValidateDataTrackingConfig) from .train import TrainTrackingConfig from .unet_config import UnetConfig from .utils import ensure_cls -from .validate import ValidateConfig +# from .validate import ValidateConfig @attr.s(kw_only=True) class TrackingConfig: @@ -20,8 +23,9 @@ class TrackingConfig: model = attr.ib(converter=ensure_cls(UnetConfig)) optimizer = attr.ib(converter=ensure_cls(OptimizerConfig)) train = attr.ib(converter=ensure_cls(TrainTrackingConfig)) - test = attr.ib(converter=ensure_cls(TestConfig)) - validate = attr.ib(converter=ensure_cls(ValidateConfig)) + train_data = attr.ib(converter=ensure_cls(TrainDataTrackingConfig)) + test_data = attr.ib(converter=ensure_cls(TestDataTrackingConfig)) + validate_data = attr.ib(converter=ensure_cls(ValidateDataTrackingConfig)) predict = attr.ib(converter=ensure_cls(PredictTrackingConfig)) extract = attr.ib(converter=ensure_cls(ExtractConfig)) solve = attr.ib(converter=ensure_cls(SolveConfig)) diff --git a/linajea/config/train.py b/linajea/config/train.py index e4d1687..6c84072 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -3,8 +3,7 @@ from .augment import (AugmentTrackingConfig, AugmentCellCycleConfig) -from .data import (DataFileConfig, - DataDBConfig) +from .train_test_validate_data import TrainDataTrackingConfig from .job import JobConfig from .utils import ensure_cls @@ -20,7 +19,6 @@ def converter(val): @attr.s(kw_only=True) class TrainConfig: - data = attr.ib(converter=ensure_cls(DataFileConfig)) job = attr.ib(converter=ensure_cls(JobConfig)) cache_size = attr.ib(type=int) max_iterations = attr.ib(type=int) @@ -48,5 +46,5 @@ class TrainTrackingConfig(TrainConfig): class TrainCellCycleConfig(TrainConfig): batch_size = attr.ib(type=int) augment = attr.ib(converter=ensure_cls(AugmentCellCycleConfig)) - use_database = attr.ib(type=bool) - database = attr.ib(converter=ensure_cls(DataDBConfig), default=None) + # use_database = attr.ib(type=bool) + # database = attr.ib(converter=ensure_cls(DataDBConfig), default=None) diff --git a/linajea/config/unet_config.py b/linajea/config/unet_config.py index a698c62..077a055 100644 --- a/linajea/config/unet_config.py +++ b/linajea/config/unet_config.py @@ -1,7 +1,6 @@ import attr from typing import List -from .data import DataConfig from .job import JobConfig from .utils import (ensure_cls, _check_nd_shape, diff --git a/linajea/config/utils.py b/linajea/config/utils.py index 09f4ae4..796bc2c 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -36,6 +36,21 @@ def converter(val): return converter +def ensure_cls_list(cl): + """If the attribute is an list of instances of cls, pass, else try constructing.""" + def converter(vals): + assert isinstance(vals, list), "list of {} expected".format(cl) + converted = [] + for val in vals: + if isinstance(val, cl) or val is None: + converted.append(val) + else: + converted.append(cl(**val)) + + return converted + return converter + + def _check_nd_shape(ndims): def _check_shape(self, attribute, value): diff --git a/linajea/config/validate.py b/linajea/config/validate.py deleted file mode 100644 index 4207884..0000000 --- a/linajea/config/validate.py +++ /dev/null @@ -1,20 +0,0 @@ -import attr -from typing import Dict, List - -from .data import (DataFileConfig, - DataDBConfig) -from .utils import ensure_cls - - -@attr.s(kw_only=True) -class ValidateConfig: - data = attr.ib(converter=ensure_cls(DataFileConfig)) - database = attr.ib(converter=ensure_cls(DataDBConfig)) - checkpoints = attr.ib(type=List[int]) - cell_score_threshold = attr.ib(type=List[float]) - - -@attr.s(kw_only=True) -class ValidateCellCycleConfig (ValidateConfig): - use_database = attr.ib(type=bool) - database = attr.ib(converter=ensure_cls(DataDBConfig), default=None) From 3b9163ca8601b0fb540acc536293e4421b2257c9 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Thu, 25 Feb 2021 06:30:49 -0500 Subject: [PATCH 028/263] config: separate subsections for eval/solve(tracking) parameters --- linajea/config/config_test.py | 4 ++-- linajea/config/evaluate.py | 18 +++++++++++++----- linajea/config/sample_config_cellcycle.toml | 6 ++++-- linajea/config/sample_config_tracking.toml | 10 +++++++--- linajea/config/solve.py | 11 ++++++++--- 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/linajea/config/config_test.py b/linajea/config/config_test.py index 10940ad..c251e36 100644 --- a/linajea/config/config_test.py +++ b/linajea/config/config_test.py @@ -10,7 +10,7 @@ PredictTrackingConfig, ExtractConfig, SolveConfig, - EvaluateConfig, + EvaluateTrackingConfig, ) if __name__ == "__main__": @@ -30,7 +30,7 @@ print(extract_config) solve_config = SolveConfig(**config_dict['solve']) # type: ignore print(solve_config) - evaluate_config = EvaluateConfig(**config_dict['evaluate']) # type: ignore + evaluate_config = EvaluateTrackingConfig(**config_dict['evaluate']) # type: ignore print(evaluate_config) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index a3608d6..42ec2a8 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -1,3 +1,5 @@ +from typing import List + import attr from .data import DataROIConfig @@ -6,21 +8,26 @@ @attr.s(kw_only=True) -class EvaluateConfig: - gt_db_name = attr.ib(type=str) +class _EvaluateParametersConfig: matching_threshold = attr.ib(type=int) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + frames = attr.ib(type=List[int]) + + +@attr.s(kw_only=True) +class _EvaluateConfig: + gt_db_name = attr.ib(type=str) job = attr.ib(converter=ensure_cls(JobConfig), default=None) - from_scratch = attr.ib(type=bool, default=False) @attr.s(kw_only=True) -class EvaluateTrackingConfig(EvaluateConfig): +class EvaluateTrackingConfig(_EvaluateConfig): from_scratch = attr.ib(type=bool, default=False) + parameters = attr.ib(converter=ensure_cls(_EvaluateParametersConfig)) @attr.s(kw_only=True) -class EvaluateCellCycleConfig(EvaluateConfig): +class EvaluateCellCycleConfig(_EvaluateConfig): max_samples = attr.ib(type=int) metric = attr.ib(type=str) use_database = attr.ib(type=bool, default=True) @@ -28,3 +35,4 @@ class EvaluateCellCycleConfig(EvaluateConfig): prob_threshold = attr.ib(type=float) dry_run = attr.ib(type=bool) find_fn = attr.ib(type=bool) + parameters = attr.ib(converter=ensure_cls(_EvaluateParametersConfig)) diff --git a/linajea/config/sample_config_cellcycle.toml b/linajea/config/sample_config_cellcycle.toml index 8da1c95..515efee 100644 --- a/linajea/config/sample_config_cellcycle.toml +++ b/linajea/config/sample_config_cellcycle.toml @@ -179,9 +179,11 @@ prob_threshold = 0.99 dry_run = true find_fn = false gt_db_name = 'linajea_120828_gt_side_1' +[evaluate.parameters] matching_threshold = 15 # optional, can overwrite validate/test roi -[evaluate.roi] +frames = [0, 200] +# or +[evaluate.parameters.roi] offset = [0, 0, 0, 0] shape = [200, 385, 512, 712] - diff --git a/linajea/config/sample_config_tracking.toml b/linajea/config/sample_config_tracking.toml index 5f6f750..ce94172 100644 --- a/linajea/config/sample_config_tracking.toml +++ b/linajea/config/sample_config_tracking.toml @@ -198,6 +198,8 @@ num_workers = 16 queue = 'local' [solve] +from_scratch = false +[solve.parameters] cost_appear = 10000000000.0 cost_disappear = 100000.0 cost_split = 0 @@ -207,16 +209,18 @@ threshold_edge_score = 5 weight_prediction_distance_cost = 1 block_size = [ 5, 500, 500, 500,] context = [ 2, 100, 100, 100,] -from_scratch = false [solve.job] num_workers = 8 queue = 'cpu' [evaluate] gt_db_name = 'linajea_120828_gt_side_1' -matching_threshold = 15 from_scratch = true +[evaluate.parameters] +matching_threshold = 15 # optional, can overwrite validate/test roi -[evaluate.roi] +frames = [0, 200] +# or +[evaluate.parameters.roi] offset = [0, 0, 0, 0] shape = [200, 385, 512, 712] diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 3afafad..5d90a9c 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -5,8 +5,8 @@ from .utils import ensure_cls -@attr.s -class SolveConfig: +@attr.s(kw_only=True) +class SolveParametersConfig: cost_appear = attr.ib(type=float) cost_disappear = attr.ib(type=float) cost_split = attr.ib(type=float) @@ -16,7 +16,12 @@ class SolveConfig: weight_prediction_distance_cost = attr.ib(type=float) block_size = attr.ib(type=List[int]) context = attr.ib(type=List[int]) - job = attr.ib(converter=ensure_cls(JobConfig)) # max_cell_move: currently use edge_move_threshold from extract max_cell_move = attr.ib(type=int, default=None) + + +@attr.s(kw_only=True) +class SolveConfig: + job = attr.ib(converter=ensure_cls(JobConfig)) from_scratch = attr.ib(type=bool, default=False) + parameters = attr.ib(converter=ensure_cls(SolveParametersConfig)) From 00088aaaaefb3583b5dd02eed91ec85ff863ba6b Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Thu, 25 Feb 2021 06:31:43 -0500 Subject: [PATCH 029/263] config: update init, post init hook to get setup --- linajea/config/__init__.py | 15 ++++++++------- linajea/config/general.py | 9 +++++++-- linajea/config/sample_config_tracking.toml | 7 +++++-- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index 5ec9ccc..65cfcaa 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -5,19 +5,20 @@ from .cnn_config import (VGGConfig, ResNetConfig, EfficientNetConfig) -from .data import DataConfig -from .evaluate import EvaluateConfig +from .data import DataFileConfig +from .evaluate import (EvaluateCellCycleConfig, + EvaluateTrackingConfig) from .extract import ExtractConfig from .general import GeneralConfig from .job import JobConfig -from .linajea_config import LinajeaConfig +# from .linajea_config import LinajeaConfig from .predict import (PredictCellCycleConfig, PredictTrackingConfig) from .solve import SolveConfig from .tracking_config import TrackingConfig -from .test import (TestConfig, - TestCellCycleConfig) +# from .test import (TestTrackingConfig, +# TestCellCycleConfig) from .train import (TrainTrackingConfig, TrainCellCycleConfig) -from .validate import (ValidateConfig, - ValidateCellCycleConfig) +# from .validate import (ValidateConfig, +# ValidateCellCycleConfig) diff --git a/linajea/config/general.py b/linajea/config/general.py index 4d9ec03..9b709a3 100644 --- a/linajea/config/general.py +++ b/linajea/config/general.py @@ -1,10 +1,12 @@ +import os + import attr @attr.s(kw_only=True) class GeneralConfig: - setup = attr.ib(type=str) - # TODO: use post_init to set setup = basename(setup_dir)? + # set via post_init hook + # setup = attr.ib(type=str) setup_dir = attr.ib(type=str) db_host = attr.ib(type=str) sample = attr.ib(type=str) @@ -13,3 +15,6 @@ class GeneralConfig: sparse = attr.ib(type=bool, default=True) seed = attr.ib(type=int) logging = attr.ib(type=int) + + def __attrs_post_init__(self): + self.setup = os.path.basename(self.setup_dir) diff --git a/linajea/config/sample_config_tracking.toml b/linajea/config/sample_config_tracking.toml index ce94172..7a3ff6b 100644 --- a/linajea/config/sample_config_tracking.toml +++ b/linajea/config/sample_config_tracking.toml @@ -1,9 +1,11 @@ [general] -setup = "setup211_simple_train_side_2" db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin" +# TODO: remove (move to data section) db_name = "linajea_120828_setup211_simple_eval_side_1_400000" +# TODO: remove (multiple samples possible) sample = "120828" -setups_dir = "../unet_setups" +# TODO: abs path? +setup_dir = "../unet_setups/setup211_simple_train_side_2" logging = 20 seed = 42 @@ -182,6 +184,7 @@ group='raw' [predict] +path_to_script = "test" write_to_zarr = false write_to_db = true processes_per_worker = 4 From f7f74e4b93fe01d1c1e41ccd9d978307a1bcc39a Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 1 Mar 2021 10:31:17 -0500 Subject: [PATCH 030/263] config: update inference data and predict_blockwise --- linajea/__init__.py | 1 + linajea/check_or_create_db.py | 24 ++++-- linajea/config/__init__.py | 1 + linajea/config/data.py | 23 ++++-- linajea/config/general.py | 2 +- linajea/config/predict.py | 1 + linajea/config/sample_config_tracking.toml | 3 +- linajea/config/tracking_config.py | 7 +- linajea/construct_zarr_filename.py | 8 +- .../process_blockwise/predict_blockwise.py | 77 ++++++++++--------- 10 files changed, 85 insertions(+), 62 deletions(-) diff --git a/linajea/__init__.py b/linajea/__init__.py index 5a4b89f..4ba426a 100644 --- a/linajea/__init__.py +++ b/linajea/__init__.py @@ -7,3 +7,4 @@ from .construct_zarr_filename import construct_zarr_filename from .check_or_create_db import checkOrCreateDB from .datasets import get_source_roi +from .get_next_inference_data import getNextInferenceData diff --git a/linajea/check_or_create_db.py b/linajea/check_or_create_db.py index 97c71db..0ff6e8b 100644 --- a/linajea/check_or_create_db.py +++ b/linajea/check_or_create_db.py @@ -4,18 +4,26 @@ import pymongo -def checkOrCreateDB(config, sample, create_if_not_found=True): - db_host = config['general']['db_host'] +def checkOrCreateDB(db_host, setup_dir, sample, checkpoint, + cell_score_threshold, prefix="linajea_", + create_if_not_found=True): + db_host = db_host info = {} - info["setup_dir"] = os.path.basename(config['general']['setup_dir']) - info["iteration"] = config['prediction']['iteration'] - info["cell_score_threshold"] = config['prediction']['cell_score_threshold'] + info["setup_dir"] = os.path.basename(setup_dir) + info["iteration"] = checkpoint + info["cell_score_threshold"] = cell_score_threshold info["sample"] = os.path.basename(sample) + return checkOrCreateDBMeta(db_host, info, prefix=prefix, + create_if_not_found=create_if_not_found) + + +def checkOrCreateDBMeta(db_host, db_meta_info, prefix="linajea_", + create_if_not_found=True): client = pymongo.MongoClient(host=db_host) for db_name in client.list_database_names(): - if not db_name.startswith("linajea_celegans_"): + if not db_name.startswith(prefix): continue db = client[db_name] @@ -34,7 +42,7 @@ def checkOrCreateDB(config, sample, create_if_not_found=True): assert query_result == 1 query_result = db["db_meta_info"].find_one() del query_result["_id"] - if query_result == info: + if query_result == db_meta_info: break else: if not create_if_not_found: @@ -42,6 +50,6 @@ def checkOrCreateDB(config, sample, create_if_not_found=True): db_name = "linajea_celegans_{}".format( datetime.datetime.now(tz=datetime.timezone.utc).strftime( '%Y%m%d_%H%M%S')) - client[db_name]["db_meta_info"].insert_one(info) + client[db_name]["db_meta_info"].insert_one(db_meta_info) return db_name diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index 65cfcaa..1ed6c9d 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -22,3 +22,4 @@ TrainCellCycleConfig) # from .validate import (ValidateConfig, # ValidateCellCycleConfig) +from .train_test_validate_data import InferenceDataTrackingConfig diff --git a/linajea/config/data.py b/linajea/config/data.py index 0baec00..9e5e9ef 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -22,17 +22,24 @@ class DataFileConfig: def __attrs_post_init__(self): if os.path.splitext(self.filename)[1] in (".n5", ".zarr"): - # TODO: use daisy to load info from file - self.file_roi = DataROIConfig(offset=[0, 0, 0, 0], - shape=[250, 500, 500, 500]) - self.file_voxel_size = [1, 5, 1, 1] + if "nested" in self.group: + store = zarr.NestedDirectoryStore(self.filename) + else: + store = self.filename + container = zarr.open(store) + attributes = zarr_container[self.group].attrs + + self.file_voxel_size = attributes.voxel_size + self.file_roi = DataROIConfig(offset=attributes.offset, + shape=attributes.shape) # type: ignore else: data_config = load_config(os.path.join(self.filename, "data_config.toml")) - self.file_voxel_size = data_config['general']['voxel_size'] + self.file_voxel_size = data_config['general']['resolution'] self.file_roi = DataROIConfig( - offset=data_config['general']['shape'], - shape=data_config['general']['shape']) + offset=data_config['general']['offset'], + shape=[s*v for s,v in zip(data_config['general']['shape'], + self.file_voxel_size)]) # type: ignore if self.group is None: self.group = data_config['general']['group'] @@ -45,7 +52,7 @@ class DataDBMetaConfig: @attr.s(kw_only=True) -class DataConfig: +class DataSourceConfig: datafile = attr.ib(converter=ensure_cls(DataFileConfig)) db_name = attr.ib(type=str, default=None) voxel_size = attr.ib(type=List[int], default=None) diff --git a/linajea/config/general.py b/linajea/config/general.py index 9b709a3..66e07c1 100644 --- a/linajea/config/general.py +++ b/linajea/config/general.py @@ -9,7 +9,7 @@ class GeneralConfig: # setup = attr.ib(type=str) setup_dir = attr.ib(type=str) db_host = attr.ib(type=str) - sample = attr.ib(type=str) + # sample = attr.ib(type=str) db_name = attr.ib(type=str, default=None) singularity_image = attr.ib(type=str, default=None) sparse = attr.ib(type=bool, default=True) diff --git a/linajea/config/predict.py b/linajea/config/predict.py index 4c5db3c..e204030 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -14,6 +14,7 @@ class PredictTrackingConfig(PredictConfig): write_to_zarr = attr.ib(type=bool, default=False) write_to_db = attr.ib(type=bool, default=True) processes_per_worker = attr.ib(type=int, default=1) + output_zarr_prefix = attr.ib(type=str, default=".") @attr.s(kw_only=True) diff --git a/linajea/config/sample_config_tracking.toml b/linajea/config/sample_config_tracking.toml index 7a3ff6b..de87c12 100644 --- a/linajea/config/sample_config_tracking.toml +++ b/linajea/config/sample_config_tracking.toml @@ -184,10 +184,11 @@ group='raw' [predict] -path_to_script = "test" +path_to_script = "predict_celegans.py" write_to_zarr = false write_to_db = true processes_per_worker = 4 +output_zarr_prefix = "/nrs/funke/hirschp/linajea_experiments" [predict.job] num_workers = 16 diff --git a/linajea/config/tracking_config.py b/linajea/config/tracking_config.py index e6bba99..b83dc3f 100644 --- a/linajea/config/tracking_config.py +++ b/linajea/config/tracking_config.py @@ -8,7 +8,8 @@ from .predict import PredictTrackingConfig from .solve import SolveConfig # from .test import TestTrackingConfig -from .train_test_validate_data import (TestDataTrackingConfig, +from .train_test_validate_data import (InferenceDataTrackingConfig, + TestDataTrackingConfig, TrainDataTrackingConfig, ValidateDataTrackingConfig) from .train import TrainTrackingConfig @@ -26,6 +27,7 @@ class TrackingConfig: train_data = attr.ib(converter=ensure_cls(TrainDataTrackingConfig)) test_data = attr.ib(converter=ensure_cls(TestDataTrackingConfig)) validate_data = attr.ib(converter=ensure_cls(ValidateDataTrackingConfig)) + inference = attr.ib(converter=ensure_cls(InferenceDataTrackingConfig), default=None) predict = attr.ib(converter=ensure_cls(PredictTrackingConfig)) extract = attr.ib(converter=ensure_cls(ExtractConfig)) solve = attr.ib(converter=ensure_cls(SolveConfig)) @@ -34,4 +36,5 @@ class TrackingConfig: @classmethod def from_file(cls, path): config_dict = load_config(path) - return cls(path=path, **config_dict) # type: ignore + config_dict["path"] = path + return cls(**config_dict) # type: ignore diff --git a/linajea/construct_zarr_filename.py b/linajea/construct_zarr_filename.py index 99a3b1f..e423851 100644 --- a/linajea/construct_zarr_filename.py +++ b/linajea/construct_zarr_filename.py @@ -1,10 +1,10 @@ import os -def construct_zarr_filename(config, sample): +def construct_zarr_filename(config, sample, checkpoint): return os.path.join( - config['general']['output_zarr_prefix'], - os.path.basename(config['general']['setup_dir']), + config.predict.output_zarr_prefix, + config.general.setup, os.path.basename(sample) + 'predictions' + - str(config['prediction']['iteration']) + '.zarr') + str(checkpoint) + '.zarr') diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index d2ca17e..c85246c 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -9,29 +9,28 @@ from funlib.run import run from .daisy_check_functions import check_function +from ..construct_zarr_filename import construct_zarr_filename from ..datasets import get_source_roi logger = logging.getLogger(__name__) -def predict_blockwise( - linajea_config, validate=False): - # if validate is true, read validation data from config, else read test +def predict_blockwise(linajea_config): setup_dir = linajea_config.general.setup_dir - if validate: - data = linajea_config.validate.data - database = linajea_config.validate.database - checkpoint = linajea_config.validate.checkpoint - # TODO: What if there are multiple data sources? - else: - data = linajea_config.test.data - database = linajea_config.test.database - checkpoint = linajea_config.test.checkpoint - voxel_size = data.voxel_size - predict_roi = data.roi - if voxel_size is None: - # TODO: get from data zarr/n5, or do in post_init of config - pass + + data = linajea_config.inference.data_source + voxel_size = daisy.Coordinate(data.voxel_size) + predict_roi = daisy.Roi(offset=data.roi.offset, + shape=data.roi.shape) + # allow for solve context + predict_roi = predict_roi.grow( + daisy.Coordinate(linajea_config.solve.parameters.context), + daisy.Coordinate(linajea_config.solve.parameters.context)) + # but limit to actual file roi + predict_roi = predict_roi.intersect( + daisy.Roi(offset=data.datafile.file_roi.offset, + shape=data.datafile.file_roi.shape)) + # get context and total input and output ROI with open(os.path.join(setup_dir, 'test_net_config.json'), 'r') as f: net_config = json.load(f) @@ -49,7 +48,9 @@ def predict_blockwise( # prepare output zarr, if necessary if linajea_config.predict.write_to_zarr: - output_zarr = construct_zarr_filename(linajea_config) + output_zarr = construct_zarr_filename(linajea_config, + data.datafile.filename, + linajea_config.inference.checkpoint) parent_vectors_ds = 'volumes/parent_vectors' cell_indicator_ds = 'volumes/cell_indicator' output_path = os.path.join(setup_dir, output_zarr) @@ -76,12 +77,14 @@ def predict_blockwise( block_read_roi = block_write_roi.grow(context, context) logger.info("Following ROIs in world units:") - logger.info("Input ROI = %s" % input_roi) - logger.info("Block read ROI = %s" % block_read_roi) - logger.info("Block write ROI = %s" % block_write_roi) - logger.info("Output ROI = %s" % output_roi) + logger.info("Input ROI = %s", input_roi) + logger.info("Block read ROI = %s", block_read_roi) + logger.info("Block write ROI = %s", block_write_roi) + logger.info("Output ROI = %s", output_roi) logger.info("Starting block-wise processing...") + logger.info("Sample: %s", data.datafile.filename) + logger.info("DB: %s", data.db_name) # process block-wise if linajea_config.predict.write_to_db: @@ -90,13 +93,11 @@ def predict_blockwise( block_read_roi, block_write_roi, process_function=lambda: predict_worker( - linajea_config.general.path, - checkpoint, - data.filename), + linajea_config), check_function=lambda b: check_function( b, 'predict', - database.db_name, + data.db_name, linajea_config.general.db_host), num_workers=linajea_config.predict.job.num_workers, read_write_conflict=False, @@ -108,46 +109,46 @@ def predict_blockwise( block_read_roi, block_write_roi, process_function=lambda: predict_worker( - linajea_config.general.path, - checkpoint, - data.filename), + linajea_config), num_workers=linajea_config.predict.job.num_workers, read_write_conflict=False, max_retries=0, fit='valid') -def predict_worker(linajea_config, checkpoint, datafile): +def predict_worker(linajea_config): worker_id = daisy.Context.from_env().worker_id worker_time = time.time() job = linajea_config.predict.job + if job.singularity_image is not None: image_path = '/nrs/funke/singularity/' image = image_path + job.singularity_image + '.img' logger.debug("Using singularity image %s" % image) else: image = None + cmd = run( - command='python -u %s --config %s --iteration %d --sample %s' % ( + command='python -u %s --config %s' % ( linajea_config.predict.path_to_script, - linajea_config.general.path, - checkpoint, - datafile), + linajea_config.path), queue=job.queue, num_gpus=1, - num_cpus=5, + num_cpus=linajea_config.predict.processes_per_worker, singularity_image=image, mount_dirs=['/groups', '/nrs'], execute=False, expand=False, - flags=['-P ' + job.lab] + flags=['-P ' + job.lab] if job.lab is not None else None ) logger.info("Starting predict worker...") logger.info("Command: %s" % str(cmd)) daisy.call( cmd, - log_out='logs/predict_%s_%d_%d.out' % (setup, worker_time, worker_id), - log_err='logs/predict_%s_%d_%d.err' % (setup, worker_time, worker_id)) + log_out='logs/predict_%s_%d_%d.out' % (linajea_config.general.setup, + worker_time, worker_id), + log_err='logs/predict_%s_%d_%d.err' % (linajea_config.general.setup, + worker_time, worker_id)) logger.info("Predict worker finished") From 8fcdd651cba779f89b0b2f9c021f7b3bdd3306bf Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 1 Mar 2021 10:32:55 -0500 Subject: [PATCH 031/263] config: update augment --- linajea/config/augment.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/linajea/config/augment.py b/linajea/config/augment.py index 2405048..c9dac14 100644 --- a/linajea/config/augment.py +++ b/linajea/config/augment.py @@ -49,13 +49,18 @@ class AugmentJitterConfig: @attr.s(kw_only=True) class AugmentConfig: - elastic = attr.ib(converter=ensure_cls(AugmentElasticConfig)) + elastic = attr.ib(converter=ensure_cls(AugmentElasticConfig), + default=None) shift = attr.ib(converter=ensure_cls(AugmentShiftConfig), default=None) - intensity = attr.ib(converter=ensure_cls(AugmentIntensityConfig)) - simple = attr.ib(converter=ensure_cls(AugmentSimpleConfig)) - noise_gaussian = attr.ib(converter=ensure_cls(AugmentNoiseGaussianConfig)) - noise_saltpepper = attr.ib(converter=ensure_cls(AugmentNoiseSaltPepperConfig)) + intensity = attr.ib(converter=ensure_cls(AugmentIntensityConfig), + default=None) + simple = attr.ib(converter=ensure_cls(AugmentSimpleConfig), + default=None) + noise_gaussian = attr.ib(converter=ensure_cls(AugmentNoiseGaussianConfig), + default=None) + noise_saltpepper = attr.ib(converter=ensure_cls(AugmentNoiseSaltPepperConfig), + default=None) @attr.s(kw_only=True) @@ -68,4 +73,5 @@ class AugmentTrackingConfig(AugmentConfig): @attr.s(kw_only=True) class AugmentCellCycleConfig(AugmentConfig): - jitter = attr.ib(converter=ensure_cls(AugmentJitterConfig)) + jitter = attr.ib(converter=ensure_cls(AugmentJitterConfig), + default=None) From 8e85b464d638a4d3f7d08e8bfa246ad67d0caa4e Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 1 Mar 2021 10:33:51 -0500 Subject: [PATCH 032/263] config: sample config: remove sample/db_name from general --- linajea/config/sample_config_cellcycle.toml | 4 +--- linajea/config/sample_config_tracking.toml | 7 +------ 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/linajea/config/sample_config_cellcycle.toml b/linajea/config/sample_config_cellcycle.toml index 515efee..019ff25 100644 --- a/linajea/config/sample_config_cellcycle.toml +++ b/linajea/config/sample_config_cellcycle.toml @@ -2,9 +2,7 @@ logging = 20 # setup = "setup211_simple_train_side_2" db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin" -db_name = "linajea_120828_setup211_simple_eval_side_1_400000" -sample = "120828" -setup_dir = "../unet_setups/setup211_simple_train_side_2" +setup_dir = "~/linajea_experiments/classifier_setups/setup211_simple_train_side_2" seed = 42 [model] diff --git a/linajea/config/sample_config_tracking.toml b/linajea/config/sample_config_tracking.toml index de87c12..fa9035a 100644 --- a/linajea/config/sample_config_tracking.toml +++ b/linajea/config/sample_config_tracking.toml @@ -1,11 +1,6 @@ [general] db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin" -# TODO: remove (move to data section) -db_name = "linajea_120828_setup211_simple_eval_side_1_400000" -# TODO: remove (multiple samples possible) -sample = "120828" -# TODO: abs path? -setup_dir = "../unet_setups/setup211_simple_train_side_2" +setup_dir = "~/linajea_experiments/unet_setups/setup211_simple_train_side_2" logging = 20 seed = 42 From eab626107a5e810dae08ce4a06076c1ab98a0600 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 1 Mar 2021 10:35:04 -0500 Subject: [PATCH 033/263] config: add path_to_script, add some default values --- linajea/config/sample_config_tracking.toml | 2 ++ linajea/config/train.py | 5 +++-- linajea/config/unet_config.py | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/linajea/config/sample_config_tracking.toml b/linajea/config/sample_config_tracking.toml index fa9035a..f2fa724 100644 --- a/linajea/config/sample_config_tracking.toml +++ b/linajea/config/sample_config_tracking.toml @@ -5,6 +5,7 @@ logging = 20 seed = 42 [model] +path_to_script = "mknet_celegans.py" train_input_shape = [ 7, 40, @@ -32,6 +33,7 @@ cell_indicator_weighted = true [train] +path_to_script = "train_celegans.py" cache_size = 10 checkpoint_stride = 10000 snapshot_stride = 1000 diff --git a/linajea/config/train.py b/linajea/config/train.py index 6c84072..2aecfe2 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -19,14 +19,15 @@ def converter(val): @attr.s(kw_only=True) class TrainConfig: + path_to_script = attr.ib(type=str) job = attr.ib(converter=ensure_cls(JobConfig)) cache_size = attr.ib(type=int) max_iterations = attr.ib(type=int) checkpoint_stride = attr.ib(type=int) snapshot_stride = attr.ib(type=int) profiling_stride = attr.ib(type=int) - use_tf_data = attr.ib(type=bool) - use_auto_mixed_precision = attr.ib(type=bool) + use_tf_data = attr.ib(type=bool, default=False) + use_auto_mixed_precision = attr.ib(type=bool, default=False) val_log_step = attr.ib(type=int) diff --git a/linajea/config/unet_config.py b/linajea/config/unet_config.py index 077a055..7b5e70f 100644 --- a/linajea/config/unet_config.py +++ b/linajea/config/unet_config.py @@ -10,6 +10,7 @@ @attr.s(kw_only=True) class UnetConfig: + path_to_script = attr.ib(type=str) # shape -> voxels, size -> world units train_input_shape = attr.ib(type=List[int], validator=[_int_list_validator, From 47fbb6734aa7cdcb8fcb316a1dd8acb654ccff78 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 1 Mar 2021 10:35:48 -0500 Subject: [PATCH 034/263] config: add missing part for data config --- linajea/config/train_test_validate_data.py | 89 ++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 linajea/config/train_test_validate_data.py diff --git a/linajea/config/train_test_validate_data.py b/linajea/config/train_test_validate_data.py new file mode 100644 index 0000000..08f1304 --- /dev/null +++ b/linajea/config/train_test_validate_data.py @@ -0,0 +1,89 @@ +from typing import List + +import attr + +from .data import (DataSourceConfig, + DataDBMetaConfig, + DataROIConfig) +from .utils import (ensure_cls, + ensure_cls_list) + + +@attr.s(kw_only=True) +class DataConfig(): + data_sources = attr.ib(converter=ensure_cls_list(DataSourceConfig)) + def __attrs_post_init__(self): + for d in self.data_sources: + if d.roi is None: + if self.roi is None: + # if roi/voxelsize not set, use info from file + d.roi = d.datafile.file_roi + else: + # if data sample specific roi/voxelsize not set, + # use general one + d.roi = self.roi + if d.voxel_size is None: + if self.voxel_size is None: + d.voxel_size = d.datafile.file_voxel_size + else: + d.voxel_size = self.voxel_size + if d.datafile.group is None: + if self.group is None: + raise ValueError("no {group} supplied for data source") + d.datafile.group = self.group + voxel_size = attr.ib(type=List[int], default=None) + roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + group = attr.ib(type=str, default=None) + + +@attr.s(kw_only=True) +class TrainDataTrackingConfig(DataConfig): + data_sources = attr.ib(converter=ensure_cls_list(DataSourceConfig)) + @data_sources.validator + def _check_train_data_source(self, attribute, value): + for ds in value: + if ds.db_name is not None: + raise ValueError("train data_sources must not have a db_name") + + +@attr.s(kw_only=True) +class TestDataTrackingConfig(DataConfig): + checkpoint = attr.ib(type=int, default=None) + cell_score_threshold = attr.ib(type=float, default=None) + + +@attr.s(kw_only=True) +class InferenceDataTrackingConfig(): + data_source = attr.ib(converter=ensure_cls(DataSourceConfig)) + checkpoint = attr.ib(type=int, default=None) + cell_score_threshold = attr.ib(type=float, default=None) + + +@attr.s(kw_only=True) +class ValidateDataTrackingConfig(DataConfig): + checkpoints = attr.ib(type=List[int]) + cell_score_threshold = attr.ib(type=float, default=None) + + + +@attr.s(kw_only=True) +class DataCellCycleConfig(DataConfig): + use_database = attr.ib(type=bool) + db_meta_info = attr.ib(converter=ensure_cls(DataDBMetaConfig), default=None) + + +@attr.s(kw_only=True) +class TrainDataCellCycleConfig(DataCellCycleConfig): + pass + + +@attr.s(kw_only=True) +class TestDataCellCycleConfig(DataCellCycleConfig): + checkpoint = attr.ib(type=int) + prob_threshold = attr.ib(type=float, default=None) + + +@attr.s(kw_only=True) +class ValidateDataCellCycleConfig(DataCellCycleConfig): + checkpoints = attr.ib(type=List[int]) + prob_thresholds = attr.ib(type=List[float], default=None) From 8b62ce1e6bd39b404f4f831c74a2c1ca2aa2a259 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 1 Mar 2021 10:36:33 -0500 Subject: [PATCH 035/263] config: update extract_edges_blockwise, add func to iterate data sources --- linajea/get_next_inference_data.py | 44 +++++++ .../extract_edges_blockwise.py | 117 +++++++++--------- 2 files changed, 101 insertions(+), 60 deletions(-) create mode 100644 linajea/get_next_inference_data.py diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py new file mode 100644 index 0000000..67a1b80 --- /dev/null +++ b/linajea/get_next_inference_data.py @@ -0,0 +1,44 @@ +from copy import deepcopy +import os +import time + +import attr +import toml + +from linajea import checkOrCreateDB +from linajea.config import (InferenceDataTrackingConfig, + TrackingConfig) + +# TODO: better name maybe? +def getNextInferenceData(args): + config = TrackingConfig.from_file(args.config) + + if args.validation: + inference = deepcopy(config.validate_data) + checkpoints = config.validate_data.checkpoints + else: + inference = deepcopy(config.test_data) + checkpoints = [config.test_data.checkpoint] + + if args.checkpoint > 0: + checkpoints = [args.checkpoint] + + os.makedirs("tmp_configs", exist_ok=True) + for checkpoint in checkpoints: + inference_data = { + 'checkpoint': checkpoint, + 'cell_score_threshold': inference.cell_score_threshold} + for sample in inference.data_sources: + if sample.db_name is None: + sample.db_name = checkOrCreateDB( + config.general.db_host, + config.general.setup_dir, + sample.datafile.filename, + checkpoint, + inference.cell_score_threshold) + inference_data['data_source'] = sample + config.inference = InferenceDataTrackingConfig(**inference_data) # type: ignore + config.path = os.path.join("tmp_configs", "config_{}.toml".format(time.time())) + with open(config.path, 'w') as f: + toml.dump(attr.asdict(config), f) + yield config diff --git a/linajea/process_blockwise/extract_edges_blockwise.py b/linajea/process_blockwise/extract_edges_blockwise.py index 93cbced..c7a1b59 100644 --- a/linajea/process_blockwise/extract_edges_blockwise.py +++ b/linajea/process_blockwise/extract_edges_blockwise.py @@ -1,60 +1,54 @@ from __future__ import absolute_import +import logging +import time + from scipy.spatial import cKDTree +import numpy as np + import daisy import linajea from .daisy_check_functions import write_done, check_function -from ..datasets import get_source_roi -import logging -import numpy as np -import time logger = logging.getLogger(__name__) -def extract_edges_blockwise( - db_host, - db_name, - sample, - edge_move_threshold, - block_size, - num_workers, - frames=None, - frame_context=1, - data_dir='../01_data', - use_pv_distance=False, - **kwargs): - - voxel_size, source_roi = get_source_roi(data_dir, sample) - - # limit to specific frames, if given - if frames: - begin, end = frames - begin -= frame_context - end += frame_context - crop_roi = daisy.Roi( - (begin, None, None, None), - (end - begin, None, None, None)) - source_roi = source_roi.intersect(crop_roi) +def extract_edges_blockwise(linajea_config): + + data = linajea_config.inference.data_source + voxel_size = daisy.Coordinate(data.voxel_size) + extract_roi = daisy.Roi(offset=data.roi.offset, + shape=data.roi.shape) + # allow for solve context + extract_roi = extract_roi.grow( + daisy.Coordinate(linajea_config.solve.parameters.context), + daisy.Coordinate(linajea_config.solve.parameters.context)) + # but limit to actual file roi + extract_roi = extract_roi.intersect( + daisy.Roi(offset=data.datafile.file_roi.offset, + shape=data.datafile.file_roi.shape)) # block size in world units block_write_roi = daisy.Roi( (0,)*4, - daisy.Coordinate(block_size)) + daisy.Coordinate(linajea_config.extract.block_size)) - pos_context = daisy.Coordinate((0,) + (edge_move_threshold,)*3) - neg_context = daisy.Coordinate((1,) + (edge_move_threshold,)*3) + max_edge_move_th = max(linajea_config.extract.edge_move_threshold.values()) + pos_context = daisy.Coordinate((0,) + (max_edge_move_th,)*3) + neg_context = daisy.Coordinate((1,) + (max_edge_move_th,)*3) logger.debug("Set neg context to %s", neg_context) - input_roi = source_roi.grow(neg_context, pos_context) + input_roi = extract_roi.grow(neg_context, pos_context) block_read_roi = block_write_roi.grow(neg_context, pos_context) - print("Following ROIs in world units:") - print("Input ROI = %s" % input_roi) - print("Block read ROI = %s" % block_read_roi) - print("Block write ROI = %s" % block_write_roi) - print("Output ROI = %s" % source_roi) + logger.info("Following ROIs in world units:") + logger.info("Input ROI = %s", input_roi) + logger.info("Block read ROI = %s", block_read_roi) + logger.info("Block write ROI = %s", block_write_roi) + logger.info("Output ROI = %s", extract_roi) - print("Starting block-wise processing...") + logger.info("Starting block-wise processing...") + logger.info("Sample: %s", data.datafile.filename) + logger.info("DB: %s", data.db_name) # process block-wise daisy.run_blockwise( @@ -62,44 +56,41 @@ def extract_edges_blockwise( block_read_roi, block_write_roi, process_function=lambda b: extract_edges_in_block( - db_name, - db_host, - edge_move_threshold, - b, - use_pv_distance=use_pv_distance), + linajea_config, + b), check_function=lambda b: check_function( b, 'extract_edges', - db_name, - db_host), - num_workers=num_workers, + data.db_name, + linajea_config.general.db_host), + num_workers=linajea_config.extract.job.num_workers, processes=True, read_write_conflict=False, - fit='shrink') + fit='overhang') def extract_edges_in_block( - db_name, - db_host, - edge_move_threshold, - block, - use_pv_distance=False): + linajea_config, + block): logger.info( "Finding edges in %s, reading from %s", block.write_roi, block.read_roi) + data = linajea_config.inference.data_source + start = time.time() graph_provider = linajea.CandidateDatabase( - db_name, - db_host, + data.db_name, + linajea_config.general.db_host, mode='r+') graph = graph_provider[block.read_roi] if graph.number_of_nodes() == 0: logger.info("No cells in roi %s. Skipping", block.read_roi) - write_done(block, 'extract_edges', db_name, db_host) + write_done(block, 'extract_edges', data.db_name, + linajea_config.general.db_host) return 0 logger.info( @@ -116,11 +107,11 @@ def extract_edges_in_block( t: [ ( cell, - np.array([data[d] for d in ['z', 'y', 'x']]), - np.array(data['parent_vector']) + np.array([attrs[d] for d in ['z', 'y', 'x']]), + np.array(attrs['parent_vector']) ) - for cell, data in graph.nodes(data=True) - if 't' in data and data['t'] == t + for cell, attrs in graph.nodes(data=True) + if 't' in attrs and attrs['t'] == t ] for t in range(t_begin - 1, t_end) } @@ -146,6 +137,11 @@ def extract_edges_in_block( kd_data = [cell[1] for cell in all_pre_cells] pre_kd_tree = cKDTree(kd_data) + for th, val in linajea_config.extract.edge_move_threshold.items(): + if th == -1 or t < int(th): + edge_move_threshold = val + break + for i, nex_cell in enumerate(cells_by_t[nex]): nex_cell_id = nex_cell[0] @@ -199,5 +195,6 @@ def extract_edges_in_block( logger.info( "Wrote edges in %.3fs", time.time() - start) - write_done(block, 'extract_edges', db_name, db_host) + write_done(block, 'extract_edges', data.db_name, + linajea_config.general.db_host) return 0 From 6302b0ff26e1fd82da303a1d50c3dece730120c7 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 1 Mar 2021 13:23:03 -0500 Subject: [PATCH 036/263] config: update solve --- linajea/config/__init__.py | 3 +- linajea/config/solve.py | 5 +- linajea/get_next_inference_data.py | 31 ++++++- linajea/process_blockwise/solve_blockwise.py | 90 ++++++++------------ 4 files changed, 69 insertions(+), 60 deletions(-) diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index 1ed6c9d..656d745 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -14,7 +14,8 @@ # from .linajea_config import LinajeaConfig from .predict import (PredictCellCycleConfig, PredictTrackingConfig) -from .solve import SolveConfig +from .solve import (SolveConfig, + SolveParametersConfig) from .tracking_config import TrackingConfig # from .test import (TestTrackingConfig, # TestCellCycleConfig) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 5d90a9c..13e4a74 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -2,7 +2,8 @@ from typing import List from .job import JobConfig -from .utils import ensure_cls +from .utils import (ensure_cls, + ensure_cls_list) @attr.s(kw_only=True) @@ -24,4 +25,4 @@ class SolveParametersConfig: class SolveConfig: job = attr.ib(converter=ensure_cls(JobConfig)) from_scratch = attr.ib(type=bool, default=False) - parameters = attr.ib(converter=ensure_cls(SolveParametersConfig)) + parameters = attr.ib(converter=ensure_cls_list(SolveParametersConfig)) diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py index 67a1b80..9812ead 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/get_next_inference_data.py @@ -5,12 +5,14 @@ import attr import toml -from linajea import checkOrCreateDB +from linajea import (CandidateDatabase, + checkOrCreateDB) from linajea.config import (InferenceDataTrackingConfig, + SolveParametersConfig, TrackingConfig) # TODO: better name maybe? -def getNextInferenceData(args): +def getNextInferenceData(args, val_param_id=None): config = TrackingConfig.from_file(args.config) if args.validation: @@ -23,12 +25,37 @@ def getNextInferenceData(args): if args.checkpoint > 0: checkpoints = [args.checkpoint] + if val_param_id is not None: + assert not args.validation, "use val_param_id to apply validation parameters with that ID to test set not validation set" + + val_db_name = checkOrCreateDB( + config.general.db_host, + config.general.setup_dir, + config.validate_data.data_sources[0].datafile.filename, + checkpoints[0], + inference.cell_score_threshold) + val_db_name = checkOrCreateDB(config, val_sample) + val_db = CandidateDatabase( + val_db_name, config['general']['db_host']) + parameters = val_db.get_parameters(val_param_id) + logger.info("getting params %s (id: %s) from validation database %s (sample: %s)", + parameters, val_param_id, val_db_name, + config.validate_data.data_sources[0].datafile.filename) + solve_parameters = [SolveParametersConfig(**parameters)] + config.solve.parameters = solve_parameters + + max_cell_move = max(config.extract.edge_move_threshold.values()) + for param_id in range(len(config.solve.parameters)): + if config.solve.parameters[param_id].max_cell_move is None: + config.solve.parameters[param_id].max_cell_move = max_cell_move + os.makedirs("tmp_configs", exist_ok=True) for checkpoint in checkpoints: inference_data = { 'checkpoint': checkpoint, 'cell_score_threshold': inference.cell_score_threshold} for sample in inference.data_sources: + sample = deepcopy(sample) if sample.db_name is None: sample.db_name = checkOrCreateDB( config.general.db_host, diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index 570ba7e..a386018 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -1,29 +1,18 @@ +import logging +import time + import daisy from linajea import CandidateDatabase from .daisy_check_functions import ( check_function, write_done, check_function_all_blocks, write_done_all_blocks) -from linajea.tracking import track, nm_track, NMTrackingParameters -from ..datasets import get_source_roi -import logging -import time +from linajea.tracking import track, nm_track logger = logging.getLogger(__name__) -def solve_blockwise( - db_host, - db_name, - sample, - parameters, # list of TrackingParameters - num_workers=8, - frames=None, - limit_to_roi=None, - from_scratch=False, - data_dir='../01_data', - cell_cycle_key=None, - **kwargs): - +def solve_blockwise(linajea_config): + parameters = linajea_config.solve.parameters block_size = daisy.Coordinate(parameters[0].block_size) context = daisy.Coordinate(parameters[0].context) # block size and context must be the same for all parameters! @@ -33,7 +22,11 @@ def solve_blockwise( (block_size, parameters[i].block_size) assert list(context) == parameters[i].context - voxel_size, source_roi = get_source_roi(data_dir, sample) + data = linajea_config.inference.data_source + db_name = data.db_name + db_host = linajea_config.general.db_host + solve_roi = daisy.Roi(offset=data.roi.offset, + shape=data.roi.shape) # determine parameters id from database graph_provider = CandidateDatabase( @@ -41,23 +34,11 @@ def solve_blockwise( db_host) parameters_id = [graph_provider.get_parameters_id(p) for p in parameters] - if from_scratch: - for pid in parameters_id: - graph_provider.set_parameters_id(pid) - graph_provider.reset_selection() - - # limit to specific frames, if given - if frames: - logger.info("Solving in frames %s" % frames) - begin, end = frames - crop_roi = daisy.Roi( - (begin, None, None, None), - (end - begin, None, None, None)) - source_roi = source_roi.intersect(crop_roi) - # limit to roi, if given - if limit_to_roi: - logger.info("limiting to roi %s" % str(limit_to_roi)) - source_roi = source_roi.intersect(limit_to_roi) + if linajea_config.solve.from_scratch: + graph_provider.reset_selection(parameter_ids=parameters_id) + if len(parameters_id) > 1: + graph_provider.database.drop_collection( + 'solve_' + str(hash(frozenset(parameters_id))) + '_daisy') block_write_roi = daisy.Roi( (0, 0, 0, 0), @@ -65,11 +46,13 @@ def solve_blockwise( block_read_roi = block_write_roi.grow( context, context) - total_roi = source_roi.grow( + total_roi = solve_roi.grow( context, context) logger.info("Solving in %s", total_roi) + logger.info("Sample: %s", data.datafile.filename) + logger.info("DB: %s", db_name) param_names = ['solve_' + str(_id) for _id in parameters_id] if len(parameters_id) > 1: @@ -102,13 +85,10 @@ def solve_blockwise( block_read_roi, block_write_roi, process_function=lambda b: solve_in_block( - db_host, - db_name, - parameters, - b, + linajea_config, parameters_id, - solution_roi=source_roi, - cell_cycle_key=cell_cycle_key), + b, + solution_roi=solve_roi), # Note: in the case of a set of parameters, # we are assuming that none of the individual parameters are # half done and only checking the hash for each block @@ -117,7 +97,7 @@ def solve_blockwise( step_name, db_name, db_host), - num_workers=num_workers, + num_workers=linajea_config.solve.job.num_workers, fit='overhang') if success: # write all done to individual parameters and set @@ -135,20 +115,19 @@ def solve_blockwise( return success -def solve_in_block( - db_host, - db_name, - parameters, - block, - parameters_id, - solution_roi=None, - cell_cycle_key=None): +def solve_in_block(linajea_config, + parameters_id, + block, + solution_roi=None): # Solution_roi is the total roi that you want a solution in # Limiting the block to the solution_roi allows you to solve # all the way to the edge, without worrying about reading # data from outside the solution roi # or paying the appear or disappear costs unnecessarily + db_name = linajea_config.inference.data_source.db_name + db_host = linajea_config.general.db_host + if len(parameters_id) == 1: step_name = 'solve_' + str(parameters_id[0]) else: @@ -202,11 +181,12 @@ def solve_in_block( frames = [read_roi.get_offset()[0], read_roi.get_offset()[0] + read_roi.get_shape()[0]] - if isinstance(parameters[0], NMTrackingParameters): - nm_track(graph, parameters, selected_keys, frames=frames) + if linajea_config.solve.non_minimal: + nm_track(graph, linajea_config.solve.parameters, selected_keys, + frames=frames) else: - track(graph, parameters, selected_keys, frames=frames, - cell_cycle_key=cell_cycle_key) + track(graph, linajea_config.solve.parameters, selected_keys, + frames=frames) start_time = time.time() graph.update_edge_attrs( write_roi, From 30f6892ec4fdb2bc05225497a531ee354b07e904 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 2 Mar 2021 10:36:12 -0500 Subject: [PATCH 037/263] config: optimizer add func to get non-None (kw)args --- linajea/config/optimizer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/linajea/config/optimizer.py b/linajea/config/optimizer.py index f8a4297..9ac4afd 100644 --- a/linajea/config/optimizer.py +++ b/linajea/config/optimizer.py @@ -22,3 +22,9 @@ class OptimizerConfig: optimizer = attr.ib(type=str, default="AdamOptimizer") args = attr.ib(converter=ensure_cls(OptimizerArgsConfig)) kwargs = attr.ib(converter=ensure_cls(OptimizerKwargsConfig)) + + def get_args(self): + return [v for v in attr.astuple(self.args) if v is not None] + + def get_kwargs(self): + return {a:v for a,v in attr.asdict(self.kwargs).items() if v is not None} From a8c38ab733840859a32664e99c48b33614d46bfa Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 2 Mar 2021 10:36:38 -0500 Subject: [PATCH 038/263] config: predict, add flag to write to db from zarr --- linajea/config/predict.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/linajea/config/predict.py b/linajea/config/predict.py index e204030..2089de2 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -11,11 +11,19 @@ class PredictConfig: @attr.s(kw_only=True) class PredictTrackingConfig(PredictConfig): path_to_script = attr.ib(type=str) + path_to_script_db_from_zarr = attr.ib(type=str, default=None) write_to_zarr = attr.ib(type=bool, default=False) write_to_db = attr.ib(type=bool, default=True) + write_db_from_zarr = attr.ib(type=bool, default=False) processes_per_worker = attr.ib(type=int, default=1) output_zarr_prefix = attr.ib(type=str, default=".") + def __attrs_post_init__(self): + assert self.write_to_zarr or self.write_to_db, \ + "prediction not written, set write_to_zarr or write_to_db to true!" + assert not self.write_db_from_zarr or \ + self.path_to_script_db_from_zarr, \ + "supply path_to_script_db_from_zarr if write_db_from_zarr is used!" @attr.s(kw_only=True) class PredictCellCycleConfig(PredictConfig): From dd3e3f8e0af86da278d7ee50dcb39fb24881352e Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 2 Mar 2021 10:37:04 -0500 Subject: [PATCH 039/263] config: solve, verfiy that block_size/context are the same for all params --- linajea/config/solve.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 13e4a74..9532891 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -26,3 +26,15 @@ class SolveConfig: job = attr.ib(converter=ensure_cls(JobConfig)) from_scratch = attr.ib(type=bool, default=False) parameters = attr.ib(converter=ensure_cls_list(SolveParametersConfig)) + + def __attrs_post_init__(self): + # block size and context must be the same for all parameters! + block_size = self.parameters[0].block_size + context = self.parameters[0].context + for i in range(len(self.parameters)): + assert block_size == self.parameters[i].block_size, \ + "%s not equal to %s" %\ + (block_size, self.parameters[i].block_size) + assert context == self.parameters[i].context, \ + "%s not equal to %s" %\ + (context, self.parameters[i].context) From e4bcc7d83d77b95d5a4c76a3a62cd54a64fa0f9c Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 2 Mar 2021 10:37:46 -0500 Subject: [PATCH 040/263] config: data, verfiy that voxel_size is the same for all samples --- linajea/config/train_test_validate_data.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/linajea/config/train_test_validate_data.py b/linajea/config/train_test_validate_data.py index 08f1304..b2d2e30 100644 --- a/linajea/config/train_test_validate_data.py +++ b/linajea/config/train_test_validate_data.py @@ -31,6 +31,10 @@ def __attrs_post_init__(self): if self.group is None: raise ValueError("no {group} supplied for data source") d.datafile.group = self.group + assert all(ds.voxel_size == self.data_sources[0].voxel_size + for ds in self.data_sources), \ + "data sources with varying voxel_size not supported" + voxel_size = attr.ib(type=List[int], default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) group = attr.ib(type=str, default=None) From 6d171109008e56ef422c4d0f9864b8a3215aef8d Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 2 Mar 2021 10:38:19 -0500 Subject: [PATCH 041/263] config: model, add option to use different test nms size --- linajea/config/unet_config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/linajea/config/unet_config.py b/linajea/config/unet_config.py index 7b5e70f..2581c7e 100644 --- a/linajea/config/unet_config.py +++ b/linajea/config/unet_config.py @@ -33,6 +33,10 @@ class UnetConfig: nms_window_shape = attr.ib(type=List[int], validator=[_int_list_validator, _check_nd_shape(3)]) + nms_window_shape_test = attr.ib( + type=List[int], default=None, + validator=attr.validators.optional([_int_list_validator, + _check_nd_shape(3)])) average_vectors = attr.ib(type=bool, default=False) unet_style = attr.ib(type=str, default='split') num_fmaps = attr.ib(type=int) From 4d92351ab024317ef99edbd21e142d3fe34c0740 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 2 Mar 2021 10:38:44 -0500 Subject: [PATCH 042/263] write_cells: print more info if mongo write fails --- linajea/gunpowder/write_cells.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/linajea/gunpowder/write_cells.py b/linajea/gunpowder/write_cells.py index beeeb90..c95adb5 100644 --- a/linajea/gunpowder/write_cells.py +++ b/linajea/gunpowder/write_cells.py @@ -101,7 +101,16 @@ def process(self, batch, request): cell_id, score, parent_vector)) if len(cells) > 0: - self.cells.insert_many(cells) + from pymongo.errors import BulkWriteError + try: + self.cells.insert_many(cells) + # bulk.execute() + except BulkWriteError as bwe: + print(bwe.details) + #you can also take this component and do more analysis + #werrors = bwe.details['writeErrors'] + raise + def get_avg_pv(parent_vectors, index, edge_length): ''' Computes the average parent vector offset from the parent vectors From 9ea609bc2020a5f11ca1cacefaf726649bc07e2f Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 2 Mar 2021 10:39:24 -0500 Subject: [PATCH 043/263] config: small fixes (solve.parameters is a list) --- .../extract_edges_blockwise.py | 4 +- .../process_blockwise/predict_blockwise.py | 93 +++++++++++-------- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/linajea/process_blockwise/extract_edges_blockwise.py b/linajea/process_blockwise/extract_edges_blockwise.py index c7a1b59..15b0c89 100644 --- a/linajea/process_blockwise/extract_edges_blockwise.py +++ b/linajea/process_blockwise/extract_edges_blockwise.py @@ -20,8 +20,8 @@ def extract_edges_blockwise(linajea_config): shape=data.roi.shape) # allow for solve context extract_roi = extract_roi.grow( - daisy.Coordinate(linajea_config.solve.parameters.context), - daisy.Coordinate(linajea_config.solve.parameters.context)) + daisy.Coordinate(linajea_config.solve.parameters[0].context), + daisy.Coordinate(linajea_config.solve.parameters[0].context)) # but limit to actual file roi extract_roi = extract_roi.intersect( daisy.Roi(offset=data.datafile.file_roi.offset, diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index c85246c..fbf1c7d 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -24,8 +24,8 @@ def predict_blockwise(linajea_config): shape=data.roi.shape) # allow for solve context predict_roi = predict_roi.grow( - daisy.Coordinate(linajea_config.solve.parameters.context), - daisy.Coordinate(linajea_config.solve.parameters.context)) + daisy.Coordinate(linajea_config.solve.parameters[0].context), + daisy.Coordinate(linajea_config.solve.parameters[0].context)) # but limit to actual file roi predict_roi = predict_roi.intersect( daisy.Roi(offset=data.datafile.file_roi.offset, @@ -46,13 +46,25 @@ def predict_blockwise(linajea_config): input_roi = predict_roi.grow(context, context) output_roi = predict_roi + # create read and write ROI + block_write_roi = daisy.Roi((0, 0, 0, 0), net_output_size) + block_read_roi = block_write_roi.grow(context, context) + + output_zarr = construct_zarr_filename(linajea_config, + data.datafile.filename, + linajea_config.inference.checkpoint) + + if linajea_config.predict.write_db_from_zarr: + assert os.path.exists(output_zarr), \ + "{} does not exist, cannot write to db from it!".format(output_zarr) + input_roi = output_roi + block_read_roi = block_write_roi + # prepare output zarr, if necessary if linajea_config.predict.write_to_zarr: - output_zarr = construct_zarr_filename(linajea_config, - data.datafile.filename, - linajea_config.inference.checkpoint) parent_vectors_ds = 'volumes/parent_vectors' cell_indicator_ds = 'volumes/cell_indicator' + maxima_ds = 'volumes/maxima' output_path = os.path.join(setup_dir, output_zarr) logger.debug("Preparing zarr at %s" % output_path) daisy.prepare_ds( @@ -71,12 +83,16 @@ def predict_blockwise(linajea_config): dtype=np.float32, write_size=net_output_size, num_channels=1) + daisy.prepare_ds( + output_path, + maxima_ds, + output_roi, + voxel_size, + dtype=np.float32, + write_size=net_output_size, + num_channels=1) - # create read and write ROI - block_write_roi = daisy.Roi((0, 0, 0, 0), net_output_size) - block_read_roi = block_write_roi.grow(context, context) - - logger.info("Following ROIs in world units:") + logger.info("Following ROIs in world units:") logger.info("Input ROI = %s", input_roi) logger.info("Block read ROI = %s", block_read_roi) logger.info("Block write ROI = %s", block_write_roi) @@ -86,34 +102,33 @@ def predict_blockwise(linajea_config): logger.info("Sample: %s", data.datafile.filename) logger.info("DB: %s", data.db_name) + # process block-wise + cf = [] + if linajea_config.predict.write_to_zarr: + cf.append(lambda b: check_function( + b, + 'predict_zarr', + data.db_name, + linajea_config.general.db_host)) if linajea_config.predict.write_to_db: - daisy.run_blockwise( - input_roi, - block_read_roi, - block_write_roi, - process_function=lambda: predict_worker( - linajea_config), - check_function=lambda b: check_function( - b, - 'predict', - data.db_name, - linajea_config.general.db_host), - num_workers=linajea_config.predict.job.num_workers, - read_write_conflict=False, - max_retries=0, - fit='valid') - else: - daisy.run_blockwise( - input_roi, - block_read_roi, - block_write_roi, - process_function=lambda: predict_worker( - linajea_config), - num_workers=linajea_config.predict.job.num_workers, - read_write_conflict=False, - max_retries=0, - fit='valid') + cf.append(lambda b: check_function( + b, + 'predict_db', + data.db_name, + linajea_config.general.db_host)) + + # process block-wise + daisy.run_blockwise( + input_roi, + block_read_roi, + block_write_roi, + process_function=lambda: predict_worker(linajea_config), + check_function=lambda b: all([f(b) for f in cf]), + num_workers=linajea_config.predict.job.num_workers, + read_write_conflict=False, + max_retries=0, + fit='overhang') def predict_worker(linajea_config): @@ -129,9 +144,13 @@ def predict_worker(linajea_config): else: image = None + if linajea_config.predict.write_db_from_zarr: + path_to_script = linajea_config.predict.path_to_script_db_from_zarr + else: + path_to_script = linajea_config.predict.path_to_script cmd = run( command='python -u %s --config %s' % ( - linajea_config.predict.path_to_script, + path_to_script, linajea_config.path), queue=job.queue, num_gpus=1, From 70901191f3963fcaaead0e1b9f71ad9063992e7f Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 2 Mar 2021 10:40:02 -0500 Subject: [PATCH 044/263] config: uniform context/block_size check moved to config --- linajea/process_blockwise/solve_blockwise.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index a386018..f9739d6 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -13,14 +13,9 @@ def solve_blockwise(linajea_config): parameters = linajea_config.solve.parameters + # block_size/context are identical for all parameters block_size = daisy.Coordinate(parameters[0].block_size) context = daisy.Coordinate(parameters[0].context) - # block size and context must be the same for all parameters! - for i in range(len(parameters)): - assert list(block_size) == parameters[i].block_size,\ - "%s not equal to %s" %\ - (block_size, parameters[i].block_size) - assert list(context) == parameters[i].context data = linajea_config.inference.data_source db_name = data.db_name From b7eb88621428ed07e81f71d259240281724d3c5a Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 3 Mar 2021 14:19:30 -0500 Subject: [PATCH 045/263] config: update candidate_database --- linajea/candidate_database.py | 123 ++++++++++++++++------------------ 1 file changed, 59 insertions(+), 64 deletions(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index 139d5a6..9e6d7ac 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -126,7 +126,7 @@ def reset_selection(self, roi=None, parameter_ids=None): def get_parameters_id( self, - tracking_parameters, + parameters, fail_if_not_exists=False): '''Get id for parameter set from mongo collection. If fail_if_not_exists, fail if the parameter set isn't already there. @@ -137,28 +137,24 @@ def get_parameters_id( params_id = None try: params_collection = self.database['parameters'] - params_dict = tracking_parameters.__dict__ - params_dict = {key: val - for key, val in params_dict.items() - if val is not None} - cnt = params_collection.count_documents(params_dict) + query = parameters.query() + cnt = params_collection.count_documents(query) if fail_if_not_exists: assert cnt > 0, "Did not find id for parameters %s"\ - " and fail_if_not_exists set to True" % params_dict - if cnt > 1: - raise RuntimeError("multiple documents found in db" - " for these parameters: %s", params_dict) - elif cnt == 1: - find_result = params_collection.find_one(params_dict) + " and fail_if_not_exists set to True" % query + assert cnt <= 1, RuntimeError("multiple documents found in db" + " for these parameters: %s", query) + if cnt == 1: + find_result = params_collection.find_one(query) logger.info("Parameters %s already in collection with id %d" - % (params_dict, find_result['_id'])) + % (query, find_result['_id'])) params_id = find_result['_id'] else: - params_id = self.insert_with_next_id(params_dict, + params_id = self.insert_with_next_id(parameters.valid(), params_collection) logger.info("Parameters %s not yet in collection," " adding with id %d", - params_dict, params_id) + query, params_id) finally: self._MongoDbGraphProvider__disconnect() @@ -211,7 +207,7 @@ def set_parameters_id(self, parameters_id): self.selected_key = 'selected_' + str(self.parameters_id) logger.debug("Set selected_key to %s" % self.selected_key) - def get_score(self, parameters_id, frames=None): + def get_score(self, parameters_id, eval_params=None): '''Returns the score for the given parameters_id, or None if no score available''' self._MongoDbGraphProvider__connect() @@ -219,28 +215,26 @@ def get_score(self, parameters_id, frames=None): score = None try: - if frames is None: - score_collection = self.database['scores'] - old_score = score_collection.find_one({'_id': parameters_id}) - else: + score_collection = self.database['scores'] + # for backwards compatibility + if eval_params.frame_start is not None: score_collection = self.database[ - 'scores'+"_".join(str(f) for f in frames)] - old_score = score_collection.find_one( - {'param_id': parameters_id, - 'frame_start': frames[0], - 'frame_end': frames[1]}) + 'scores_' + + str(eval_params.frame_start) + "_" + + str(eval_params.frame_end)] + + query = {'param_id': parameters_id} + query.update(eval_params.valid()) + old_score = score_collection.find_one(query) if old_score: - if frames is None: - del old_score['_id'] - else: - del old_score['param_id'] + del old_score['_id'] score = old_score - + logger.info("loaded score for %s", query) finally: self._MongoDbGraphProvider__disconnect() return score - def get_scores(self, frames=None, filters=None): + def get_scores(self, frames=None, filters=None, eval_params=None): '''Returns the a list of all score dictionaries or None if no score available''' self._MongoDbGraphProvider__connect() @@ -252,24 +246,27 @@ def get_scores(self, frames=None, filters=None): else: query = {} - if frames is None: - score_collection = self.database['scores'] - scores = list(score_collection.find(query)) - - else: + score_collection = self.database['scores'] + # for backwards compatibility + if eval_params.frame_start is not None: score_collection = self.database[ - 'scores'+"_".join(str(f) for f in frames)] - scores = list(score_collection.find(query)) + 'scores_' + + str(eval_params.frame_start) + "_" + + str(eval_params.frame_end)] + + query.update(eval_params.valid()) + scores = list(score_collection.find(query)) logger.debug("Found %d scores" % len(scores)) - if frames is None: - for score in scores: + # for backwards compatibility + for score in scores: + if 'param_id' not in score: score['param_id'] = score['_id'] finally: self._MongoDbGraphProvider__disconnect() return scores - def write_score(self, parameters_id, report, frames=None): + def write_score(self, parameters_id, report, eval_params=None): '''Writes the score for the given parameters_id to the scores collection, along with the associated parameters''' parameters = self.get_parameters(parameters_id) @@ -280,30 +277,28 @@ def write_score(self, parameters_id, report, frames=None): self._MongoDbGraphProvider__connect() self._MongoDbGraphProvider__open_db() try: - - if frames is None: - score_collection = self.database['scores'] - eval_dict = {'_id': parameters_id} - else: + score_collection = self.database['scores'] + # for backwards compatibility + if eval_params.frame_start is not None: score_collection = self.database[ - 'scores'+"_".join(str(f) for f in frames)] - eval_dict = {'param_id': parameters_id} - if parameters is not None: - eval_dict.update(parameters) - logger.info("%s %s", frames, eval_dict) - eval_dict.update(report.__dict__) - if frames is None: - score_collection.replace_one({'_id': parameters_id}, - eval_dict, - upsert=True) - else: - eval_dict.update({'frame_start': frames[0], - 'frame_end': frames[1]}) - score_collection.replace_one({'param_id': parameters_id, - 'frame_start': frames[0], - 'frame_end': frames[1]}, - eval_dict, - upsert=True) + 'scores_' + + str(eval_params.frame_start) + "_" + + str(eval_params.frame_end)] + + query = {'param_id': parameters_id} + query.update(eval_params.valid()) + + cnt = score_collection.count_documents(query) + assert cnt <= 1, "multiple scores for query %s exist, don't know which to overwrite" % query + + if parameters is None: + parameters = {} + logger.info("writing scores for %s to %s", parameters, query) + parameters.update(report.__dict__) + parameters.update(query) + score_collection.replace_one(query, + parameters, + upsert=True) finally: self._MongoDbGraphProvider__disconnect() From e9c3809f30284cf1ef495446fc3262432ad4bfd3 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 3 Mar 2021 14:20:55 -0500 Subject: [PATCH 046/263] config: update config for evaluate scripts --- linajea/config/data.py | 1 + linajea/config/evaluate.py | 34 ++++++++++++++++++++-- linajea/config/sample_config_tracking.toml | 14 +++++---- linajea/config/solve.py | 15 ++++++++++ 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/linajea/config/data.py b/linajea/config/data.py index 9e5e9ef..36acdf8 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -55,5 +55,6 @@ class DataDBMetaConfig: class DataSourceConfig: datafile = attr.ib(converter=ensure_cls(DataFileConfig)) db_name = attr.ib(type=str, default=None) + gt_db_name = attr.ib(type=str, default=None) voxel_size = attr.ib(type=List[int], default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 42ec2a8..1fea695 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -11,12 +11,42 @@ class _EvaluateParametersConfig: matching_threshold = attr.ib(type=int) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) - frames = attr.ib(type=List[int]) + # deprecated + frames = attr.ib(type=List[int], default=None) + # deprecated + frame_start = attr.ib(type=int, default=None) + # deprecated + frame_end = attr.ib(type=int, default=None) + # deprecated + limit_to_roi_offset = attr.ib(type=List[int], default=None) + # deprecated + limit_to_roi_shape = attr.ib(type=List[int], default=None) + # deprecated + sparse = attr.ib(type=bool) + + def __attrs_post_init__(self): + if self.frames is not None and \ + self.frame_start is None and self.frame_end is None: + self.frame_start = self.frames[0] + self.frame_end = self.frames[1] + self.frames = None + + def valid(self): + return {key: val + for key, val in attr.asdict(self).items() + if val is not None} + + def query(self): + params_dict_valid = self.valid() + params_dict_none = {key: {"$exists": False} + for key, val in attr.asdict(self).items() + if val is None} + query = {**params_dict_valid, **params_dict_none} + return query @attr.s(kw_only=True) class _EvaluateConfig: - gt_db_name = attr.ib(type=str) job = attr.ib(converter=ensure_cls(JobConfig), default=None) diff --git a/linajea/config/sample_config_tracking.toml b/linajea/config/sample_config_tracking.toml index f2fa724..f535014 100644 --- a/linajea/config/sample_config_tracking.toml +++ b/linajea/config/sample_config_tracking.toml @@ -141,6 +141,7 @@ shape = [200, 385, 512, 712] [[test_data.data_sources]] voxel_size = [1, 5, 1, 1] db_name = "linajea_some_db_name_test_A" +gt_db_name = 'linajea_120828_gt_side_1' [test_data.data_sources.datafile] filename = "/nrs/funke/malinmayorc/120828/FILE_A.n5" group='raw' @@ -151,6 +152,7 @@ shape = [200, 385, 512, 712] [[test_data.data_sources]] voxel_size = [1, 5, 1, 1] db_name = "linajea_some_db_name_test_B" +gt_db_name = 'linajea_120828_gt_side_1' [test_data.data_sources.datafile] filename = "/nrs/funke/malinmayorc/120828/FILE_B.n5" group='raw' @@ -169,12 +171,14 @@ shape = [200, 385, 512, 712] [[validate_data.data_sources]] db_name = "linajea_some_db_name_A" +gt_db_name = 'linajea_120828_gt_side_1' [validate_data.data_sources.datafile] filename = "/nrs/funke/malinmayorc/120828/FILE_A.n5" group='raw' [[validate_data.data_sources]] db_name = "linajea_some_db_name_B" +gt_db_name = 'linajea_120828_gt_side_1' [validate_data.data_sources.datafile] filename = "/nrs/funke/malinmayorc/120828/FILE_B.n5" group='raw' @@ -200,7 +204,11 @@ queue = 'local' [solve] from_scratch = false -[solve.parameters] +[solve.job] +num_workers = 8 +queue = 'cpu' + +[[solve.parameters]] cost_appear = 10000000000.0 cost_disappear = 100000.0 cost_split = 0 @@ -210,12 +218,8 @@ threshold_edge_score = 5 weight_prediction_distance_cost = 1 block_size = [ 5, 500, 500, 500,] context = [ 2, 100, 100, 100,] -[solve.job] -num_workers = 8 -queue = 'cpu' [evaluate] -gt_db_name = 'linajea_120828_gt_side_1' from_scratch = true [evaluate.parameters] matching_threshold = 15 diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 9532891..7c08bec 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -1,6 +1,7 @@ import attr from typing import List +from .data import DataROIConfig from .job import JobConfig from .utils import (ensure_cls, ensure_cls_list) @@ -19,6 +20,20 @@ class SolveParametersConfig: context = attr.ib(type=List[int]) # max_cell_move: currently use edge_move_threshold from extract max_cell_move = attr.ib(type=int, default=None) + roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + + def valid(self): + return {key: val + for key, val in attr.asdict(self).items() + if val is not None} + + def query(self): + params_dict_valid = self.valid() + params_dict_none = {key: {"$exists": False} + for key, val in attr.asdict(self).items() + if val is None} + query = {**params_dict_valid, **params_dict_none} + return query @attr.s(kw_only=True) From 6ad2e419f1ada14713d8b2ba4d6be76b0b261f62 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 3 Mar 2021 14:21:23 -0500 Subject: [PATCH 047/263] config: update evaluate_setup --- linajea/evaluation/evaluate_setup.py | 87 +++++++++++----------------- 1 file changed, 34 insertions(+), 53 deletions(-) diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index f911609..d33156d 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -1,68 +1,47 @@ -import linajea.tracking -from .evaluate import evaluate -from ..datasets import get_source_roi import logging -import daisy -import time import sys +import time + +import daisy + +import linajea.tracking +from .evaluate import evaluate logger = logging.getLogger(__name__) -def evaluate_setup( - sample, - db_host, - db_name, - gt_db_name, - matching_threshold=None, - frames=None, - limit_to_roi=None, - from_scratch=True, - sparse=True, - data_dir='../01_data', - **kwargs): - - parameters = linajea.tracking.TrackingParameters(**kwargs) - if matching_threshold is None: - logger.error("No matching threshold for evaluation") - sys.exit() +def evaluate_setup(linajea_config): + + assert len(linajea_config.solve.parameters) == 1, "can only handle single parameter set" + parameters = linajea_config.solve.parameters[0] + + data = linajea_config.inference.data_source + db_name = data.db_name + db_host = linajea_config.general.db_host + evaluate_roi = daisy.Roi(offset=data.roi.offset, + shape=data.roi.shape) # determine parameters id from database - results_db = linajea.CandidateDatabase( - db_name, - db_host) + results_db = linajea.CandidateDatabase(db_name, db_host) parameters_id = results_db.get_parameters_id(parameters) - if not from_scratch: - old_score = results_db.get_score(parameters_id, frames=frames) + if not linajea_config.evaluate.from_scratch: + old_score = results_db.get_score(parameters_id, + linajea_config.evaluate.parameters) if old_score: - logger.info("Already evaluated %d (frames: %s). Skipping" % - (parameters_id, frames)) + logger.info("Already evaluated %d (%s). Skipping" % + (parameters_id, linajea_config.evaluate.parameters)) return old_score - voxel_size, source_roi = get_source_roi(data_dir, sample) - - # limit to specific frames, if given - if frames: - begin, end = frames - crop_roi = daisy.Roi( - (begin, None, None, None), - (end - begin, None, None, None)) - source_roi = source_roi.intersect(crop_roi) - - # limit to roi, if given - if limit_to_roi: - source_roi.intersect(limit_to_roi) - - logger.info("Evaluating in %s", source_roi) + logger.info("Evaluating in %s", evaluate_roi) - edges_db = linajea.CandidateDatabase( - db_name, db_host, parameters_id=parameters_id) + edges_db = linajea.CandidateDatabase(db_name, db_host, + parameters_id=parameters_id) logger.info("Reading cells and edges in db %s with parameter_id %d" % (db_name, parameters_id)) start_time = time.time() - subgraph = edges_db.get_selected_graph(source_roi) + subgraph = edges_db.get_selected_graph(evaluate_roi) logger.info("Read %d cells and %d edges in %s seconds" % (subgraph.number_of_nodes(), @@ -76,12 +55,13 @@ def evaluate_setup( track_graph = linajea.tracking.TrackGraph( subgraph, frame_key='t', roi=subgraph.roi) - gt_db = linajea.CandidateDatabase(gt_db_name, db_host) + gt_db = linajea.CandidateDatabase( + linajea_config.inference.data_source.gt_db_name, db_host) logger.info("Reading ground truth cells and edges in db %s" - % gt_db_name) + % linajea_config.inference.data_source.gt_db_name) start_time = time.time() - gt_subgraph = gt_db[source_roi] + gt_subgraph = gt_db[evaluate_roi] logger.info("Read %d cells and %d edges in %s seconds" % (gt_subgraph.number_of_nodes(), gt_subgraph.number_of_edges(), @@ -93,9 +73,10 @@ def evaluate_setup( report = evaluate( gt_track_graph, track_graph, - matching_threshold=matching_threshold, - sparse=sparse) + matching_threshold=linajea_config.evaluate.parameters.matching_threshold, + sparse=linajea_config.general.sparse) logger.info("Done evaluating results for %d. Saving results to mongo." % parameters_id) - results_db.write_score(parameters_id, report, frames=frames) + results_db.write_score(parameters_id, report, + eval_params=linajea_config.evaluate.parameters) From 109c4676def592c68bfb1fcfbf7838c34eb27378 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 3 Mar 2021 14:21:52 -0500 Subject: [PATCH 048/263] config: update get_next_data func for solve/evaluate --- linajea/get_next_inference_data.py | 103 ++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 23 deletions(-) diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py index 9812ead..aa77248 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/get_next_inference_data.py @@ -1,4 +1,5 @@ from copy import deepcopy +import logging import os import time @@ -11,8 +12,11 @@ SolveParametersConfig, TrackingConfig) +logger = logging.getLogger(__name__) + + # TODO: better name maybe? -def getNextInferenceData(args, val_param_id=None): +def getNextInferenceData(args, is_solve=False, is_evaluate=False): config = TrackingConfig.from_file(args.config) if args.validation: @@ -25,31 +29,18 @@ def getNextInferenceData(args, val_param_id=None): if args.checkpoint > 0: checkpoints = [args.checkpoint] - if val_param_id is not None: - assert not args.validation, "use val_param_id to apply validation parameters with that ID to test set not validation set" - - val_db_name = checkOrCreateDB( - config.general.db_host, - config.general.setup_dir, - config.validate_data.data_sources[0].datafile.filename, - checkpoints[0], - inference.cell_score_threshold) - val_db_name = checkOrCreateDB(config, val_sample) - val_db = CandidateDatabase( - val_db_name, config['general']['db_host']) - parameters = val_db.get_parameters(val_param_id) - logger.info("getting params %s (id: %s) from validation database %s (sample: %s)", - parameters, val_param_id, val_db_name, - config.validate_data.data_sources[0].datafile.filename) - solve_parameters = [SolveParametersConfig(**parameters)] - config.solve.parameters = solve_parameters + if is_solve and (args.param_id is not None or args.val_param_id is not None): + config = fix_solve_pid(args, config, checkpoints, inference) + if is_evaluate and args.param_id is not None: + config = fix_evaluate_pid(args, config, checkpoints, inference) max_cell_move = max(config.extract.edge_move_threshold.values()) - for param_id in range(len(config.solve.parameters)): - if config.solve.parameters[param_id].max_cell_move is None: - config.solve.parameters[param_id].max_cell_move = max_cell_move + for pid in range(len(config.solve.parameters)): + if config.solve.parameters[pid].max_cell_move is None: + config.solve.parameters[pid].max_cell_move = max_cell_move os.makedirs("tmp_configs", exist_ok=True) + solve_parameters_sets = deepcopy(config.solve.parameters) for checkpoint in checkpoints: inference_data = { 'checkpoint': checkpoint, @@ -65,7 +56,73 @@ def getNextInferenceData(args, val_param_id=None): inference.cell_score_threshold) inference_data['data_source'] = sample config.inference = InferenceDataTrackingConfig(**inference_data) # type: ignore - config.path = os.path.join("tmp_configs", "config_{}.toml".format(time.time())) + if is_solve or is_evaluate: + config = fix_solve_roi(config) + + if is_evaluate: + config = fix_evaluate_roi(config) + for solve_parameters in solve_parameters_sets: + config.solve.parameters = [solve_parameters] + yield config + continue + + config.path = os.path.join("tmp_configs", "config_{}.toml".format( + time.time())) with open(config.path, 'w') as f: toml.dump(attr.asdict(config), f) yield config + + +def fix_solve_pid(args, config, checkpoints, inference): + if args.param_id is not None: + assert len(checkpoints) == 1, "use param_id to reevaluate a single instance" + sample_name = inference.data_sources[0].datafile.filename, + pid = args.param_id + else: + assert not args.validation, "use val_param_id to apply validation parameters with that ID to test set not validation set" + sample_name = config.validate_data.data_sources[0].datafile.filename + pid = args.val_param_id + + config = fix_solve_parameters_with_pid(config, sample_name, checkpoints[0], + inference, pid) + + return config + + +def fix_solve_roi(config): + for i in range(len(config.solve.parameters)): + config.solve.parameters[i].roi = config.inference.data_source.roi + return config + + +def fix_evaluate_pid(args, config, checkpoints, inference): + assert len(checkpoints) == 1, "use param_id to reevaluate a single instance" + sample_name = inference.data_sources[0].datafile.filename, + pid = args.param_id + + config = fix_solve_parameters_with_pid(config, sample_name, checkpoints[0], + inference, pid) + return config + +def fix_evaluate_roi(config): + if config.evaluate.parameters.roi is not None: + config.inference.data_source.roi = config.evaluate.parameters.roi + return config + + + +def fix_solve_parameters_with_pid(config, sample_name, checkpoint, inference, + pid): + db_name = checkOrCreateDB( + config.general.db_host, + config.general.setup_dir, + sample_name, + checkpoint, + inference.cell_score_threshold) + db = CandidateDatabase(db_name, config.general.db_host) + parameters = db.get_parameters(pid) + logger.info("getting params %s (id: %s) from validation database %s (sample: %s)", + parameters, pid, db_name, sample_name) + solve_parameters = [SolveParametersConfig(**parameters)] # type: ignore + config.solve.parameters = solve_parameters + return config From 2aa7607d8f29ba76cb2bbb0adb2a4ee89fa53a3e Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 3 Mar 2021 14:22:39 -0500 Subject: [PATCH 049/263] write_cells: use logging instead of print --- linajea/gunpowder/write_cells.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/linajea/gunpowder/write_cells.py b/linajea/gunpowder/write_cells.py index c95adb5..5cc8e5e 100644 --- a/linajea/gunpowder/write_cells.py +++ b/linajea/gunpowder/write_cells.py @@ -2,6 +2,7 @@ import gunpowder as gp import numpy as np import pymongo +from pymongo.errors import BulkWriteError import logging logger = logging.getLogger(__name__) @@ -101,14 +102,10 @@ def process(self, batch, request): cell_id, score, parent_vector)) if len(cells) > 0: - from pymongo.errors import BulkWriteError try: self.cells.insert_many(cells) - # bulk.execute() except BulkWriteError as bwe: - print(bwe.details) - #you can also take this component and do more analysis - #werrors = bwe.details['writeErrors'] + logger.error(bwe.details) raise From f6a283555f4b3d4d42ba4cdafc7eb8a2db747677 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 10 Mar 2021 08:28:59 -0500 Subject: [PATCH 050/263] config: a few small additions/fixes to config classes --- linajea/config/__init__.py | 3 ++- linajea/config/augment.py | 9 ++++++++- linajea/config/cnn_config.py | 10 +++++++--- linajea/config/data.py | 4 +++- linajea/config/evaluate.py | 9 +++++++-- linajea/config/predict.py | 5 +++-- linajea/config/train.py | 1 + linajea/config/train_test_validate_data.py | 11 +++++++++++ 8 files changed, 42 insertions(+), 10 deletions(-) diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index 656d745..0091766 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -23,4 +23,5 @@ TrainCellCycleConfig) # from .validate import (ValidateConfig, # ValidateCellCycleConfig) -from .train_test_validate_data import InferenceDataTrackingConfig +from .train_test_validate_data import (InferenceDataTrackingConfig, + InferenceDataCellCycleConfig) diff --git a/linajea/config/augment.py b/linajea/config/augment.py index c9dac14..87a84ca 100644 --- a/linajea/config/augment.py +++ b/linajea/config/augment.py @@ -10,7 +10,8 @@ class AugmentElasticConfig: jitter_sigma = attr.ib(type=List[int]) rotation_min = attr.ib(type=int) rotation_max = attr.ib(type=int) - subsample = attr.ib(type=int) + subsample = attr.ib(type=int, default=1) + use_fast_points_transform = attr.ib(type=bool, default=False) @attr.s(kw_only=True) @@ -69,9 +70,15 @@ class AugmentTrackingConfig(AugmentConfig): norm_bounds = attr.ib(type=List[int]) divisions = attr.ib(type=bool) # float for percentage? normalization = attr.ib(type=str, default=None) + perc_min = attr.ib(type=str) + perc_max = attr.ib(type=str) @attr.s(kw_only=True) class AugmentCellCycleConfig(AugmentConfig): + min_key = attr.ib(type=str) + max_key = attr.ib(type=str) + norm_min = attr.ib(type=int, default=None) + norm_max = attr.ib(type=int, default=None) jitter = attr.ib(converter=ensure_cls(AugmentJitterConfig), default=None) diff --git a/linajea/config/cnn_config.py b/linajea/config/cnn_config.py index db492d2..0c01579 100644 --- a/linajea/config/cnn_config.py +++ b/linajea/config/cnn_config.py @@ -10,6 +10,7 @@ @attr.s(kw_only=True) class CNNConfig: + path_to_script = attr.ib(type=str) # shape -> voxels, size -> world units input_shape = attr.ib(type=List[int], validator=[_int_list_validator, @@ -26,16 +27,17 @@ class CNNConfig: use_global_pool = attr.ib(type=bool, default=True) use_conv4d = attr.ib(type=bool, default=True) num_classes = attr.ib(type=int, default=3) + classes = attr.ib(type=List[str]) + class_ids = attr.ib(type=List[int]) network_type = attr.ib( type=str, validator=attr.validators.in_(["vgg", "resnet", "efficientnet"])) make_isotropic = attr.ib(type=int, default=False) - + regularizer_weight = attr.ib(type=float, default=None) @attr.s(kw_only=True) class VGGConfig(CNNConfig): - num_fmaps = attr.ib(type=List[int], - validator=_int_list_validator) + num_fmaps = attr.ib(type=int) fmap_inc_factors = attr.ib(type=List[int], validator=_int_list_validator) downsample_factors = attr.ib(type=List[List[int]], @@ -53,6 +55,8 @@ class ResNetConfig(CNNConfig): num_blocks = attr.ib(default=None, type=List[int], validator=_int_list_validator) use_bottleneck = attr.ib(default=None, type=bool) + num_fmaps = attr.ib(type=List[int], + validator=_int_list_validator) @attr.s(kw_only=True) diff --git a/linajea/config/data.py b/linajea/config/data.py index 36acdf8..16b1d02 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -2,6 +2,7 @@ from typing import List import attr +import zarr from linajea import load_config from .utils import ensure_cls @@ -27,7 +28,7 @@ def __attrs_post_init__(self): else: store = self.filename container = zarr.open(store) - attributes = zarr_container[self.group].attrs + attributes = container[self.group].attrs self.file_voxel_size = attributes.voxel_size self.file_roi = DataROIConfig(offset=attributes.offset, @@ -49,6 +50,7 @@ class DataDBMetaConfig: setup_dir = attr.ib(type=str, default=None) checkpoint = attr.ib(type=int) cell_score_threshold = attr.ib(type=float) + voxel_size = attr.ib(type=List[int], default=None) @attr.s(kw_only=True) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 1fea695..0cb7788 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -56,13 +56,18 @@ class EvaluateTrackingConfig(_EvaluateConfig): parameters = attr.ib(converter=ensure_cls(_EvaluateParametersConfig)) +@attr.s(kw_only=True) +class _EvaluateParametersCellCycleConfig: + matching_threshold = attr.ib() + roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + + @attr.s(kw_only=True) class EvaluateCellCycleConfig(_EvaluateConfig): max_samples = attr.ib(type=int) metric = attr.ib(type=str) - use_database = attr.ib(type=bool, default=True) one_off = attr.ib(type=bool) prob_threshold = attr.ib(type=float) dry_run = attr.ib(type=bool) find_fn = attr.ib(type=bool) - parameters = attr.ib(converter=ensure_cls(_EvaluateParametersConfig)) + parameters = attr.ib(converter=ensure_cls(_EvaluateParametersCellCycleConfig)) diff --git a/linajea/config/predict.py b/linajea/config/predict.py index 2089de2..960f7a4 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -6,11 +6,10 @@ @attr.s(kw_only=True) class PredictConfig: job = attr.ib(converter=ensure_cls(JobConfig)) - + path_to_script = attr.ib(type=str) @attr.s(kw_only=True) class PredictTrackingConfig(PredictConfig): - path_to_script = attr.ib(type=str) path_to_script_db_from_zarr = attr.ib(type=str, default=None) write_to_zarr = attr.ib(type=bool, default=False) write_to_db = attr.ib(type=bool, default=True) @@ -28,3 +27,5 @@ def __attrs_post_init__(self): @attr.s(kw_only=True) class PredictCellCycleConfig(PredictConfig): batch_size = attr.ib(type=int) + max_samples = attr.ib(type=int, default=None) + prefix = attr.ib(type=str, default="") diff --git a/linajea/config/train.py b/linajea/config/train.py index 2aecfe2..848d07f 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -41,6 +41,7 @@ class TrainTrackingConfig(TrainConfig): parent_vectors_loss_transition = attr.ib(type=int, default=50000) use_radius = attr.ib(type=Dict[int, int], converter=use_radius_converter()) + cell_density = attr.ib(default=None) @attr.s(kw_only=True) diff --git a/linajea/config/train_test_validate_data.py b/linajea/config/train_test_validate_data.py index b2d2e30..003abb1 100644 --- a/linajea/config/train_test_validate_data.py +++ b/linajea/config/train_test_validate_data.py @@ -85,9 +85,20 @@ class TrainDataCellCycleConfig(DataCellCycleConfig): class TestDataCellCycleConfig(DataCellCycleConfig): checkpoint = attr.ib(type=int) prob_threshold = attr.ib(type=float, default=None) + skip_predict = attr.ib(type=bool) @attr.s(kw_only=True) class ValidateDataCellCycleConfig(DataCellCycleConfig): checkpoints = attr.ib(type=List[int]) prob_thresholds = attr.ib(type=List[float], default=None) + skip_predict = attr.ib(type=bool) + + +@attr.s(kw_only=True) +class InferenceDataCellCycleConfig(): + data_source = attr.ib(converter=ensure_cls(DataSourceConfig)) + checkpoint = attr.ib(type=int, default=None) + prob_threshold = attr.ib(type=float) + use_database = attr.ib(type=bool) + db_meta_info = attr.ib(converter=ensure_cls(DataDBMetaConfig), default=None) From 97aee3fb9b5bfae23f535e0c3f6bc93f00949e10 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 05:38:58 -0400 Subject: [PATCH 051/263] cand_db: check explicitly for param_id is None "if parameters_id:" is also false if parameters_id == 0 --- linajea/candidate_database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index 9e6d7ac..79c6fcd 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -64,7 +64,7 @@ def __init__( ) self.parameters_id = None self.selected_key = None - if parameters_id: + if parameters_id is not None: self.set_parameters_id(parameters_id) def get_selected_graph(self, roi, edge_attrs=None): From b5dffe7b44fada056807766d7f3c04eb8e977466 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 05:41:07 -0400 Subject: [PATCH 052/263] config: set default of perc norm. bounds to None --- linajea/config/augment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linajea/config/augment.py b/linajea/config/augment.py index 87a84ca..55e5452 100644 --- a/linajea/config/augment.py +++ b/linajea/config/augment.py @@ -70,8 +70,8 @@ class AugmentTrackingConfig(AugmentConfig): norm_bounds = attr.ib(type=List[int]) divisions = attr.ib(type=bool) # float for percentage? normalization = attr.ib(type=str, default=None) - perc_min = attr.ib(type=str) - perc_max = attr.ib(type=str) + perc_min = attr.ib(type=str, default=None) + perc_max = attr.ib(type=str, default=None) @attr.s(kw_only=True) From 4c1a373a5ba1ca9c8dac0103743544ec31987e40 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 05:42:14 -0400 Subject: [PATCH 053/263] config: data: add optional file_track_range parameter --- linajea/config/data.py | 2 ++ linajea/config/train_test_validate_data.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/linajea/config/data.py b/linajea/config/data.py index 16b1d02..7b7d3a9 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -20,6 +20,7 @@ class DataFileConfig: group = attr.ib(type=str, default=None) file_roi = attr.ib(default=None) file_voxel_size = attr.ib(default=None) + file_track_range = attr.ib(type=List[int], default=None) def __attrs_post_init__(self): if os.path.splitext(self.filename)[1] in (".n5", ".zarr"): @@ -41,6 +42,7 @@ def __attrs_post_init__(self): offset=data_config['general']['offset'], shape=[s*v for s,v in zip(data_config['general']['shape'], self.file_voxel_size)]) # type: ignore + self.file_track_range = data_config['general'].get('track_range') if self.group is None: self.group = data_config['general']['group'] diff --git a/linajea/config/train_test_validate_data.py b/linajea/config/train_test_validate_data.py index 003abb1..236c0a0 100644 --- a/linajea/config/train_test_validate_data.py +++ b/linajea/config/train_test_validate_data.py @@ -1,3 +1,5 @@ +import daisy + from typing import List import attr @@ -22,6 +24,22 @@ def __attrs_post_init__(self): # if data sample specific roi/voxelsize not set, # use general one d.roi = self.roi + file_roi = daisy.Roi(offset=d.datafile.file_roi.offset, + shape=d.datafile.file_roi.shape) + roi = daisy.Roi(offset=d.roi.offset, + shape=d.roi.shape) + roi = roi.intersect(file_roi) + if d.datafile.file_track_range is not None: + begin_frame, end_frame = d.datafile.file_track_range + track_range_roi = daisy.Roi( + offset=[begin_frame, None, None, None], + shape=[end_frame-begin_frame+1, None, None, None]) + roi = roi.intersect(track_range_roi) + # d.roi.offset[0] = max(begin_frame, d.roi.offset[0]) + # d.roi.shape[0] = min(end_frame - begin_frame + 1, + # d.roi.shape[0]) + d.roi.offset = roi.get_offset() + d.roi.shape = roi.get_shape() if d.voxel_size is None: if self.voxel_size is None: d.voxel_size = d.datafile.file_voxel_size @@ -31,6 +49,7 @@ def __attrs_post_init__(self): if self.group is None: raise ValueError("no {group} supplied for data source") d.datafile.group = self.group + assert all(ds.voxel_size == self.data_sources[0].voxel_size for ds in self.data_sources), \ "data sources with varying voxel_size not supported" From b8b34e5265d6486b611b72d697fc8ccc9353aa48 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 05:43:07 -0400 Subject: [PATCH 054/263] config: solve: new vs old ilp parameters, gen. search parameters distinguish between new (minimal) and old (non_minimal) ilp parameters move functionality to generate grid/random search parameters into config --- linajea/config/solve.py | 179 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 7c08bec..8d561d4 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -1,4 +1,8 @@ import attr +import itertools +import logging +import os +import random from typing import List from .data import DataROIConfig @@ -6,9 +10,60 @@ from .utils import (ensure_cls, ensure_cls_list) +logger = logging.getLogger(__name__) @attr.s(kw_only=True) class SolveParametersConfig: + track_cost = attr.ib(type=float) + weight_node_score = attr.ib(type=float) + selection_constant = attr.ib(type=float) + weight_division = attr.ib(type=float) + division_constant = attr.ib(type=float) + weight_child = attr.ib(type=float) + weight_continuation = attr.ib(type=float) + weight_edge_score = attr.ib(type=float) + cell_cycle_key = attr.ib(type=str, default=None) + block_size = attr.ib(type=List[int]) + context = attr.ib(type=List[int]) + # max_cell_move: currently use edge_move_threshold from extract + max_cell_move = attr.ib(type=int, default=None) + roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + + def valid(self): + return {key: val + for key, val in attr.asdict(self).items() + if val is not None} + + def query(self): + params_dict_valid = self.valid() + params_dict_none = {key: {"$exists": False} + for key, val in attr.asdict(self).items() + if val is None} + query = {**params_dict_valid, **params_dict_none} + return query + + +@attr.s(kw_only=True) +class SolveParametersSearchConfig: + track_cost = attr.ib(type=List[float]) + weight_node_score = attr.ib(type=List[float]) + selection_constant = attr.ib(type=List[float]) + weight_division = attr.ib(type=List[float]) + division_constant = attr.ib(type=List[float]) + weight_child = attr.ib(type=List[float]) + weight_continuation = attr.ib(type=List[float]) + weight_edge_score = attr.ib(type=List[float]) + cell_cycle_key = attr.ib(type=str, default=None) + block_size = attr.ib(type=List[List[int]]) + context = attr.ib(type=List[List[int]]) + # max_cell_move: currently use edge_move_threshold from extract + max_cell_move = attr.ib(type=List[int], default=None) + random_search = attr.ib(type=bool, default=False) + num_random_configs = attr.ib(type=int, default=None) + + +@attr.s(kw_only=True) +class SolveParametersNonMinimalConfig: cost_appear = attr.ib(type=float) cost_disappear = attr.ib(type=float) cost_split = attr.ib(type=float) @@ -16,6 +71,13 @@ class SolveParametersConfig: weight_node_score = attr.ib(type=float) threshold_edge_score = attr.ib(type=float) weight_prediction_distance_cost = attr.ib(type=float) + use_cell_state = attr.ib(type=str, default=None) + threshold_split_score = attr.ib(type=float, default=None) + threshold_is_normal_score = attr.ib(type=float, default=None) + threshold_is_daughter_score = attr.ib(type=float, default=None) + cost_daughter = attr.ib(type=float, default=None) + cost_normal = attr.ib(type=float, default=None) + prefix = attr.ib(type=str, default=None) block_size = attr.ib(type=List[int]) context = attr.ib(type=List[int]) # max_cell_move: currently use edge_move_threshold from extract @@ -36,13 +98,128 @@ def query(self): return query +@attr.s(kw_only=True) +class SolveParametersNonMinimalSearchConfig: + cost_appear = attr.ib(type=List[float]) + cost_disappear = attr.ib(type=List[float]) + cost_split = attr.ib(type=List[float]) + threshold_node_score = attr.ib(type=List[float]) + weight_node_score = attr.ib(type=List[float]) + threshold_edge_score = attr.ib(type=List[float]) + weight_prediction_distance_cost = attr.ib(type=List[float]) + use_cell_state = attr.ib(type=List[float], default=None) + threshold_split_score = attr.ib(type=List[float], default=None) + threshold_is_normal_score = attr.ib(type=List[float], default=None) + threshold_is_daughter_score = attr.ib(type=List[float], default=None) + cost_daughter = attr.ib(type=List[float], default=None) + cost_normal = attr.ib(type=List[float], default=None) + prefix = attr.ib(type=str, default=None) + block_size = attr.ib(type=List[List[int]]) + context = attr.ib(type=List[List[int]]) + # max_cell_move: currently use edge_move_threshold from extract + max_cell_move = attr.ib(type=List[int], default=None) + random_search = attr.ib(type=bool, default=False) + num_random_configs = attr.ib(type=int, default=None) + +def write_solve_parameters_configs(parameters_search, non_minimal): + params = attr.asdict(parameters_search) + del params['random_search'] + del params['num_random_configs'] + + search_keys = list(params.keys()) + + if parameters_search.random_search: + search_configs = [] + assert parameters_search.num_random_configs is not None, \ + "set number_configs kwarg when using random search!" + + for _ in range(parameters_search.num_random_configs): + conf = [] + for _, v in params.items(): + if not isinstance(v, list): + conf.append(v) + elif len(v) == 1: + conf.append(v[0]) + elif isinstance(v[0], str): + conf.append(random.choice(v)) + else: + assert len(v) == 2, \ + "possible options per parameter for random search: " \ + "single fixed value, upper and lower bound, " \ + "set of string values" + if isinstance(v[0], list): + idx = random.randrange(len(v[0])) + conf.append(random.uniform(v[0][idx], v[1][idx])) + else: + conf.append(random.uniform(v[0], v[1])) + search_configs.append(conf) + else: + search_configs = itertools.product(*[params[key] + for key in search_keys]) + + configs = [] + for config_vals in search_configs: + logger.debug("Config vals %s" % str(config_vals)) + if non_minimal: + configs.append(SolveParametersNonMinimalConfig( + **config_vals)) # type: ignore + else: + configs.append(SolveParametersConfig( + **config_vals)) # type: ignore + + return configs + + @attr.s(kw_only=True) class SolveConfig: job = attr.ib(converter=ensure_cls(JobConfig)) from_scratch = attr.ib(type=bool, default=False) - parameters = attr.ib(converter=ensure_cls_list(SolveParametersConfig)) + parameters = attr.ib(converter=ensure_cls_list( + SolveParametersConfig), default=None) + parameters_search = attr.ib(converter=ensure_cls( + SolveParametersSearchConfig), default=None) + parameters_non_minimal = attr.ib(converter=ensure_cls_list( + SolveParametersNonMinimalConfig), default=None) + parameters_non_minimal_search = attr.ib(converter=ensure_cls( + SolveParametersNonMinimalSearchConfig), default=None) + non_minimal = attr.ib(type=bool, default=False) + write_struct_svm = attr.ib(type=bool, default=False) + check_node_close_to_roi = attr.ib(type=bool, default=True) + add_node_density_constraints = attr.ib(type=bool, default=False) def __attrs_post_init__(self): + assert self.parameters is not None or \ + self.parameters_search is not None or \ + self.parameters_non_minimal is not None or \ + self.parameters_non_minimal_search is not None, \ + "provide either solve parameters or grid/random search values " \ + "for solve parameters!" + + if self.parameters is not None or self.parameters_search is not None: + assert not self.non_minimal, \ + "please set non_minimal to false when using minimal ilp" + elif self.parameters_non_minimal is not None or \ + self.parameters_non_minimal_search is not None: + assert self.non_minimal, \ + "please set non_minimal to true when using non minimal ilp" + + if self.parameters_search is not None: + if self.parameters is not None: + logger.warning("overwriting explicit solve parameters with " + "grid/random search parameters!") + self.parameters = write_solve_parameters_configs( + self.parameters_search, non_minimal=False) + elif self.parameters_non_minimal_search is not None: + if self.parameters_non_minimal is not None: + logger.warning("overwriting explicit solve parameters with " + "grid/random search parameters!") + self.parameters = write_solve_parameters_configs( + self.parameters_non_minimal_search, non_minimal=True) + elif self.parameters_non_minimal is not None: + assert self.parameters is None, \ + "overwriting minimal ilp parameters with non-minimal ilp ones" + self.parameters = self.parameters_non_minimal + # block size and context must be the same for all parameters! block_size = self.parameters[0].block_size context = self.parameters[0].context From 12c708692551d250d63f208f7c4cba641cef40a3 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 05:45:08 -0400 Subject: [PATCH 055/263] config: list converter: list might be None --- linajea/config/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/linajea/config/utils.py b/linajea/config/utils.py index 796bc2c..62144e8 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -39,7 +39,10 @@ def converter(val): def ensure_cls_list(cl): """If the attribute is an list of instances of cls, pass, else try constructing.""" def converter(vals): - assert isinstance(vals, list), "list of {} expected".format(cl) + if vals is None: + return None + + assert isinstance(vals, list), "list of {} expected ({})".format(cl, vals) converted = [] for val in vals: if isinstance(val, cl) or val is None: From db65031e1b3591e26d976b024c457d2d2c8d4391 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 05:46:05 -0400 Subject: [PATCH 056/263] eval: print short report after eval (without nodes lists) --- linajea/evaluation/evaluate_setup.py | 1 + linajea/evaluation/report.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index d33156d..24506ae 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -78,5 +78,6 @@ def evaluate_setup(linajea_config): logger.info("Done evaluating results for %d. Saving results to mongo." % parameters_id) + logger.info("Result summary: %s", report.get_short_report()) results_db.write_score(parameters_id, report, eval_params=linajea_config.evaluate.parameters) diff --git a/linajea/evaluation/report.py b/linajea/evaluation/report.py index ad2848c..4217718 100644 --- a/linajea/evaluation/report.py +++ b/linajea/evaluation/report.py @@ -184,3 +184,19 @@ def set_aeftl_and_erl(self, aeftl, erl): def set_validation_score(self, validation_score): self.validation_score = validation_score + + def get_report(self): + return self.__dict__ + + def get_short_report(self): + report = self.__dict__ + # STATISTICS + del report['fn_edge_list'] + del report['identity_switch_gt_nodes'] + del report['fp_div_rec_nodes'] + del report['no_connection_gt_nodes'] + del report['unconnected_child_gt_nodes'] + del report['unconnected_parent_gt_nodes'] + del report['tp_div_gt_nodes'] + + return report From eadfbf829d2db162ae09ea03a690f7f07589a359 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 05:48:11 -0400 Subject: [PATCH 057/263] config: fix small things in func to get next data sample --- linajea/get_next_inference_data.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py index aa77248..8b43871 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/get_next_inference_data.py @@ -29,7 +29,7 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): if args.checkpoint > 0: checkpoints = [args.checkpoint] - if is_solve and (args.param_id is not None or args.val_param_id is not None): + if is_solve and args.val_param_id is not None: config = fix_solve_pid(args, config, checkpoints, inference) if is_evaluate and args.param_id is not None: config = fix_evaluate_pid(args, config, checkpoints, inference) @@ -56,12 +56,14 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): inference.cell_score_threshold) inference_data['data_source'] = sample config.inference = InferenceDataTrackingConfig(**inference_data) # type: ignore - if is_solve or is_evaluate: + if is_solve: config = fix_solve_roi(config) if is_evaluate: config = fix_evaluate_roi(config) for solve_parameters in solve_parameters_sets: + solve_parameters = deepcopy(solve_parameters) + solve_parameters.roi = config.inference.data_source.roi config.solve.parameters = [solve_parameters] yield config continue @@ -97,7 +99,7 @@ def fix_solve_roi(config): def fix_evaluate_pid(args, config, checkpoints, inference): assert len(checkpoints) == 1, "use param_id to reevaluate a single instance" - sample_name = inference.data_sources[0].datafile.filename, + sample_name = inference.data_sources[0].datafile.filename pid = args.param_id config = fix_solve_parameters_with_pid(config, sample_name, checkpoints[0], @@ -106,6 +108,9 @@ def fix_evaluate_pid(args, config, checkpoints, inference): def fix_evaluate_roi(config): if config.evaluate.parameters.roi is not None: + assert config.evaluate.parameters.roi.shape[0] < \ + config.inference.data_source.roi.shape[0], \ + "your evaluation ROI is larger than your data roi!" config.inference.data_source.roi = config.evaluate.parameters.roi return config From 2b6cbd28f9ebffb996c45e56e87f0b0231784ed0 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 05:53:34 -0400 Subject: [PATCH 058/263] update tracks_source for newer gunpowder (points -> graph) --- linajea/gunpowder/tracks_source.py | 51 +++++++++++++++--------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/linajea/gunpowder/tracks_source.py b/linajea/gunpowder/tracks_source.py index 3b616c9..e0a7690 100644 --- a/linajea/gunpowder/tracks_source.py +++ b/linajea/gunpowder/tracks_source.py @@ -1,5 +1,5 @@ -from gunpowder import (Point, Coordinate, Batch, BatchProvider, - Roi, PointsSpec, Points) +from gunpowder import (Node, Coordinate, Batch, BatchProvider, + Roi, GraphSpec, Graph) from gunpowder.profiling import Timing import numpy as np import logging @@ -9,19 +9,15 @@ logger = logging.getLogger(__name__) -class TrackPoint(Point): +class TrackNode(Node): - def __init__(self, location, parent_id, track_id, value=None): - - super(TrackPoint, self).__init__(location) - - self.thaw() - self.original_location = np.array(location, dtype=np.float32) - self.parent_id = parent_id - self.track_id = track_id - self.value = value - self.freeze() + def __init__(self, id, location, parent_id, track_id, value=None): + attrs = {"original_location": np.array(location, dtype=np.float32), + "parent_id": parent_id, + "track_id": track_id, + "value": value} + super(TrackNode, self).__init__(id, location, attrs=attrs) class TracksSource(BatchProvider): '''Read tracks of points from a comma-separated-values text file. @@ -53,13 +49,13 @@ class TracksSource(BatchProvider): The file to read from. - points (:class:`PointsKey`): + points (:class:`GraphKey`): The key of the points set to create. - points_spec (:class:`PointsSpec`, optional): + points_spec (:class:`GraphSpec`, optional): - An optional :class:`PointsSpec` to overwrite the points specs + An optional :class:`GraphSpec` to overwrite the points specs automatically determined from the CSV file. This is useful to set the :class:`Roi` manually, for example. @@ -94,7 +90,7 @@ def setup(self): roi = Roi(min_bb, max_bb - min_bb) - self.provides(self.points, PointsSpec(roi=roi)) + self.provides(self.points, GraphSpec(roi=roi)) def provide(self, request): @@ -117,11 +113,11 @@ def provide(self, request): points_data = self._get_points(point_filter) logger.debug("Points data: %s", points_data) - logger.debug("Type of point: %s", type(list(points_data.values())[0])) - points_spec = PointsSpec(roi=request[self.points].roi.copy()) + logger.debug("Type of point: %s", type(points_data[0])) + points_spec = GraphSpec(roi=request[self.points].roi.copy()) batch = Batch() - batch.points[self.points] = Points(points_data, points_spec) + batch.points[self.points] = Graph(points_data, [], points_spec) timing.stop() batch.profiling_stats.add(timing) @@ -133,9 +129,13 @@ def _get_points(self, point_filter): filtered_locations = self.locations[point_filter] filtered_track_info = self.track_info[point_filter] - return { - # point_id - track_info[0]: TrackPoint( + nodes = [] + for location, track_info in zip(filtered_locations, + filtered_track_info): + t = location[0] + node = TrackNode( + # point_id + track_info[0], location, # parent_id track_info[1] if track_info[1] > 0 else None, @@ -143,9 +143,8 @@ def _get_points(self, point_filter): track_info[2], # radius value=track_info[3] if len(track_info) > 3 else None) - for location, track_info in zip(filtered_locations, - filtered_track_info) - } + nodes.append(node) + return nodes def _read_points(self): roi = self.points_spec.roi if self.points_spec is not None else None From d9eb0a044ddee97e291fa9b816876159f8250a6a Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 05:54:59 -0400 Subject: [PATCH 059/263] tracks_source: optionally insert radius into data stream --- linajea/gunpowder/tracks_source.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/linajea/gunpowder/tracks_source.py b/linajea/gunpowder/tracks_source.py index e0a7690..f16a619 100644 --- a/linajea/gunpowder/tracks_source.py +++ b/linajea/gunpowder/tracks_source.py @@ -66,12 +66,17 @@ class TracksSource(BatchProvider): positions to convert them to world units. ''' - def __init__(self, filename, points, points_spec=None, scale=1.0): + def __init__(self, filename, points, points_spec=None, scale=1.0, + use_radius=False): self.filename = filename self.points = points self.points_spec = points_spec self.scale = scale + if isinstance(use_radius, dict): + self.use_radius = {int(k):v for k,v in use_radius.items()} + else: + self.use_radius = use_radius self.locations = None self.track_info = None @@ -133,6 +138,14 @@ def _get_points(self, point_filter): for location, track_info in zip(filtered_locations, filtered_track_info): t = location[0] + if isinstance(self.use_radius, dict): + for th in sorted(self.use_radius.keys()): + if t < int(th): + value = track_info[3] + value[0] = self.use_radius[th] + break + else: + value = track_info[3] if self.use_radius else None node = TrackNode( # point_id track_info[0], @@ -142,7 +155,7 @@ def _get_points(self, point_filter): # track_id track_info[2], # radius - value=track_info[3] if len(track_info) > 3 else None) + value=value) nodes.append(node) return nodes From 684b6163879c2d1896801066355ed257e370c921 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 06:04:24 -0400 Subject: [PATCH 060/263] solve: track: pass config instead of parameters --- linajea/process_blockwise/solve_blockwise.py | 5 ++--- linajea/tracking/non_minimal_track.py | 14 ++++++++------ linajea/tracking/track.py | 11 ++++++----- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index f9739d6..4232341 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -177,10 +177,9 @@ def solve_in_block(linajea_config, frames = [read_roi.get_offset()[0], read_roi.get_offset()[0] + read_roi.get_shape()[0]] if linajea_config.solve.non_minimal: - nm_track(graph, linajea_config.solve.parameters, selected_keys, - frames=frames) + nm_track(graph, linajea_config, selected_keys, frames=frames) else: - track(graph, linajea_config.solve.parameters, selected_keys, + track(graph, linajea_config, selected_keys, frames=frames) start_time = time.time() graph.update_edge_attrs( diff --git a/linajea/tracking/non_minimal_track.py b/linajea/tracking/non_minimal_track.py index 5048ce0..e01cad2 100644 --- a/linajea/tracking/non_minimal_track.py +++ b/linajea/tracking/non_minimal_track.py @@ -7,7 +7,7 @@ logger = logging.getLogger(__name__) -def nm_track(graph, parameters, selected_key, frame_key='t', frames=None): +def nm_track(graph, config, selected_key, frame_key='t', frames=None): ''' A wrapper function that takes a daisy subgraph and input parameters, creates and solves the ILP to create tracks, and updates the daisy subgraph to reflect the selected nodes and edges. @@ -18,10 +18,11 @@ def nm_track(graph, parameters, selected_key, frame_key='t', frames=None): The candidate graph to extract tracks from - parameters (``NMTrackingParameters``) + config (``TrackingConfig``) - The parameters to use when optimizing the tracking ILP - Can also be a list of parameters. + Configuration object to be used. The parameters to use when + optimizing the tracking ILP are at config.solve.parameters + (can also be a list of parameters). selected_key (``string``) @@ -45,6 +46,7 @@ def nm_track(graph, parameters, selected_key, frame_key='t', frames=None): if graph.number_of_nodes() == 0: return + parameters = config.solve.parameters if not isinstance(parameters, list): parameters = [parameters] selected_key = [selected_key] @@ -63,8 +65,8 @@ def nm_track(graph, parameters, selected_key, frame_key='t', frames=None): total_solve_time = 0 for parameter, key in zip(parameters, selected_key): if not solver: - solver = NMSolver(track_graph, parameter, key, - frames=frames) + solver = NMSolver( + track_graph, parameter, key, frames=frames) else: solver.update_objective(parameter, key) diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index b38e7d4..15cc1e6 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -7,8 +7,7 @@ logger = logging.getLogger(__name__) -def track(graph, parameters, selected_key, - frame_key='t', frames=None, cell_cycle_key=None): +def track(graph, config, selected_key, frame_key='t', frames=None) ''' A wrapper function that takes a daisy subgraph and input parameters, creates and solves the ILP to create tracks, and updates the daisy subgraph to reflect the selected nodes and edges. @@ -19,10 +18,11 @@ def track(graph, parameters, selected_key, The candidate graph to extract tracks from - parameters (``TrackingParameters``) + config (``TrackingConfig``) - The parameters to use when optimizing the tracking ILP. - Can also be a list of parameters. + Configuration object to be used. The parameters to use when + optimizing the tracking ILP are at config.solve.parameters + (can also be a list of parameters). selected_key (``string``) @@ -65,6 +65,7 @@ def track(graph, parameters, selected_key, logger.info("No nodes in graph - skipping solving step") return + parameters = config.solve.parameters if not isinstance(parameters, list): parameters = [parameters] selected_key = [selected_key] From cd5d1046cda3d9479c40b54af4034be066c92549 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 06:11:19 -0400 Subject: [PATCH 061/263] solver: add option to write ssvm data, rename vgg_key use write_struct_svm toggle to write features, indicators and constraints to disk during solving block_id is additionally passed to solver (id of daisy block) rename vgg_key to cell_cycle_key, to be set in config.solve.parameters (modified check if nodes have key in track.py, when doing search, nodes have to have all keys) --- linajea/process_blockwise/solve_blockwise.py | 2 +- linajea/tracking/solver.py | 310 ++++++++++++++++--- linajea/tracking/track.py | 30 +- 3 files changed, 289 insertions(+), 53 deletions(-) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index 4232341..22f8edd 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -180,7 +180,7 @@ def solve_in_block(linajea_config, nm_track(graph, linajea_config, selected_keys, frames=frames) else: track(graph, linajea_config, selected_keys, - frames=frames) + frames=frames, block_id=block.block_id) start_time = time.time() graph.update_edge_attrs( write_roi, diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index 63f4720..893ac56 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -1,4 +1,4 @@ -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- import logging import pylp @@ -12,16 +12,15 @@ class Solver(object): This is the "minimal" version, simplified to minimize the number of hyperparamters ''' - def __init__(self, track_graph, parameters, selected_key, - vgg_key=None, frames=None): + def __init__(self, track_graph, parameters, selected_key, frames=None, + write_struct_svm=False, block_id=None) # frames: [start_frame, end_frame] where start_frame is inclusive # and end_frame is exclusive. Defaults to track_graph.begin, # track_graph.end + self.write_struct_svm = write_struct_svm + self.block_id = block_id self.graph = track_graph - self.parameters = parameters - self.selected_key = selected_key - self.vgg_key = vgg_key self.start_frame = frames[0] if frames else self.graph.begin self.end_frame = frames[1] if frames else self.graph.end @@ -41,9 +40,9 @@ def __init__(self, track_graph, parameters, selected_key, self.solver = None self._create_indicators() - self._set_objective() - self._add_constraints() self._create_solver() + self._create_constraints() + self.update_objective(parameters, selected_key) def update_objective(self, parameters, selected_key): self.parameters = parameters @@ -65,18 +64,13 @@ def _create_solver(self): self.num_vars, pylp.VariableType.Binary, preference=pylp.Preference.Gurobi) - self.solver.set_objective(self.objective) - all_constraints = pylp.LinearConstraints() - for c in self.main_constraints + self.pin_constraints: - all_constraints.add(c) - self.solver.set_constraints(all_constraints) self.solver.set_num_threads(1) self.solver.set_timeout(120) def solve(self): solution, message = self.solver.solve() logger.info(message) - logger.debug("costs of solution: %f", solution.get_value()) + logger.info("costs of solution: %f", solution.get_value()) for v in self.graph.nodes: self.graph.nodes[v][self.selected_key] = solution[ @@ -95,7 +89,22 @@ def _create_indicators(self): # 2. appear # 3. disappear # 4. split + if self.write_struct_svm: + node_selected_file = open(f"node_selected_b{self.block_id}", 'w') + node_appear_file = open(f"node_appear_b{self.block_id}", 'w') + node_disappear_file = open(f"node_disappear_b{self.block_id}", 'w') + node_split_file = open(f"node_split_b{self.block_id}", 'w') + node_child_file = open(f"node_child_b{self.block_id}", 'w') + node_continuation_file = open(f"node_continuation_b{self.block_id}", 'w') for node in self.graph.nodes: + if self.write_struct_svm: + node_selected_file.write("{} {}\n".format(node, self.num_vars)) + node_appear_file.write("{} {}\n".format(node, self.num_vars + 1)) + node_disappear_file.write("{} {}\n".format(node, self.num_vars + 2)) + node_split_file.write("{} {}\n".format(node, self.num_vars + 3)) + node_child_file.write("{} {}\n".format(node, self.num_vars + 4)) + node_continuation_file.write("{} {}\n".format(node, self.num_vars + 5)) + self.node_selected[node] = self.num_vars self.node_appear[node] = self.num_vars + 1 self.node_disappear[node] = self.num_vars + 2 @@ -104,10 +113,26 @@ def _create_indicators(self): self.node_continuation[node] = self.num_vars + 5 self.num_vars += 6 + if self.write_struct_svm: + node_selected_file.close() + node_appear_file.close() + node_disappear_file.close() + node_split_file.close() + node_child_file.close() + node_continuation_file.close() + + if self.write_struct_svm: + edge_selected_file = open(f"edge_selected_b{self.block_id}", 'w') for edge in self.graph.edges(): + if self.write_struct_svm: + edge_selected_file.write("{} {} {}\n".format(edge[0], edge[1], self.num_vars)) + self.edge_selected[edge] = self.num_vars self.num_vars += 1 + if self.write_struct_svm: + edge_selected_file.close() + def _set_objective(self): logger.debug("setting objective") @@ -115,36 +140,80 @@ def _set_objective(self): objective = pylp.LinearObjective(self.num_vars) # node selection and cell cycle costs + if self.write_struct_svm: + node_selected_weight_file = open( + f"features_node_selected_weight_b{self.block_id}", 'w') + node_selected_constant_file = open( + f"features_node_selected_constant_b{self.block_id}", 'w') + node_split_weight_file = open( + f"features_node_split_weight_b{self.block_id}", 'w') + node_split_constant_file = open( + f"features_node_split_constant_b{self.block_id}", 'w') + node_child_weight_or_constant_file = open( + f"features_node_child_weight_or_constant_b{self.block_id}", 'w') + node_continuation_weight_or_constant_file = open( + f"features_node_continuation_weight_or_constant_b{self.block_id}", 'w') for node in self.graph.nodes: objective.set_coefficient( self.node_selected[node], - self._node_costs(node)) + self._node_costs(node, + node_selected_weight_file, + node_selected_constant_file)) objective.set_coefficient( self.node_split[node], - self._split_costs(node)) + self._split_costs(node, + node_split_weight_file, + node_split_constant_file)) objective.set_coefficient( self.node_child[node], - self._child_costs(node)) + self._child_costs( + node, + node_child_weight_or_constant_file)) objective.set_coefficient( self.node_continuation[node], - self._continuation_costs(node)) + self._continuation_costs( + node, + node_continuation_weight_or_constant_file)) + + if self.write_struct_svm: + node_selected_weight_file.close() + node_selected_constant_file.close() + node_split_weight_file.close() + node_split_constant_file.close() + node_child_weight_or_constant_file.close() + node_continuation_weight_or_constant_file.close() # edge selection costs + if self.write_struct_svm: + edge_selected_weight_file = open( + f"features_edge_selected_weight_b{self.block_id}", 'w') for edge in self.graph.edges(): objective.set_coefficient( self.edge_selected[edge], - self._edge_costs(edge)) + self._edge_costs(edge, + edge_selected_weight_file)) + if self.write_struct_svm: + edge_selected_weight_file.close() # node appear (skip first frame) + if self.write_struct_svm: + appear_file = open(f"features_node_appear_b{self.block_id}", 'w') + disappear_file = open(f"features_node_disappear_b{self.block_id}", 'w') for t in range(self.start_frame + 1, self.end_frame): for node in self.graph.cells_by_frame(t): objective.set_coefficient( self.node_appear[node], self.parameters.track_cost) + if self.write_struct_svm: + appear_file.write("{} 1\n".format(self.node_appear[node])) + disappear_file.write("{} 0\n".format(self.node_disappear[node])) for node in self.graph.cells_by_frame(self.start_frame): objective.set_coefficient( self.node_appear[node], 0) + if self.write_struct_svm: + appear_file.write("{} 0\n".format(self.node_appear[node])) + disappear_file.write("{} 0\n".format(self.node_disappear[node])) # remove node appear costs at edge of roi for node, data in self.graph.nodes(data=True): @@ -155,12 +224,22 @@ def _set_objective(self): objective.set_coefficient( self.node_appear[node], 0) + if self.write_struct_svm: + appear_file.write("{} 0\n".format( + self.node_appear[node])) + + if self.write_struct_svm: + appear_file.close() + disappear_file.close() self.objective = objective def _check_node_close_to_roi_edge(self, node, data, distance): '''Return true if node is within distance to the z,y,x edge of the roi. Assumes 4D data with t,z,y,x''' + if isinstance(distance, dict): + distance = min(distance.values()) + begin = self.graph.roi.get_begin()[1:] end = self.graph.roi.get_end()[1:] for index, dim in enumerate(['z', 'y', 'x']): @@ -180,52 +259,100 @@ def _check_node_close_to_roi_edge(self, node, data, distance): self.graph.roi)) return False - def _node_costs(self, node): + def _node_costs(self, node, file_weight, file_constant): # node score times a weight plus a threshold score_costs = ((self.graph.nodes[node]['score'] * self.parameters.weight_node_score) + self.parameters.selection_constant) + + if self.write_struct_svm: + file_weight.write("{} {}\n".format( + self.node_selected[node], + self.graph.nodes[node]['score'])) + file_constant.write("{} 1\n".format(self.node_selected[node])) + return score_costs - def _split_costs(self, node): + def _split_costs(self, node, file_weight, file_constant): # split score times a weight plus a threshold - if self.vgg_key is None: + if self.parameters.cell_cycle_key is None: + file_constant.write("{} 1\n".format(self.node_split[node])) return 1 - split_costs = ((self.graph.nodes[node][self.vgg_key][0] * - self.parameters.weight_division) + - self.parameters.division_constant) + split_costs = ( + ( + # self.graph.nodes[node][self.parameters.cell_cycle_key][0] * + self.graph.nodes[node][self.parameters.cell_cycle_key+"mother"] * + self.parameters.weight_division) + + self.parameters.division_constant) + + if self.write_struct_svm: + file_weight.write("{} {}\n".format( + self.node_split[node], + # self.graph.nodes[node][self.parameters.cell_cycle_key][0] + self.graph.nodes[node][self.parameters.cell_cycle_key+"mother"] + )) + file_constant.write("{} 1\n".format(self.node_split[node])) + return split_costs - def _child_costs(self, node): + def _child_costs(self, node, file_weight_or_constant): # split score times a weight - if self.vgg_key is None: + if self.parameters.cell_cycle_key is None: + file_weight_or_constant.write("{} 1\n".format( + self.node_child[node])) return 0 - split_costs = (self.graph.nodes[node][self.vgg_key][1] * - self.parameters.weight_child) + split_costs = ( + # self.graph.nodes[node][self.parameters.cell_cycle_key][1] * + self.graph.nodes[node][self.parameters.cell_cycle_key+"daughter"] * + self.parameters.weight_child) + + if self.write_struct_svm: + file_weight_or_constant.write("{} {}\n".format( + self.node_child[node], + # self.graph.nodes[node][self.parameters.cell_cycle_key][1] + self.graph.nodes[node][self.parameters.cell_cycle_key+"daughter"] + )) + return split_costs - def _continuation_costs(self, node): + def _continuation_costs(self, node, file_weight_or_constant): # split score times a weight - if self.vgg_key is None: + if self.parameters.cell_cycle_key is None: + file_weight_or_constant.write("{} 1\n".format( + self.node_child[node])) return 0 - continuation_costs = (self.graph.nodes[node][self.vgg_key][2] * - self.parameters.weight_continuation) + continuation_costs = ( + # self.graph.nodes[node][self.parameters.cell_cycle_key][2] * + self.graph.nodes[node][self.parameters.cell_cycle_key+"normal"] * + self.parameters.weight_continuation) + + if self.write_struct_svm: + file_weight_or_constant.write("{} {}\n".format( + self.node_continuation[node], + # self.graph.nodes[node][self.parameters.cell_cycle_key][2] + self.graph.nodes[node][self.parameters.cell_cycle_key+"normal"] + )) + return continuation_costs - def _edge_costs(self, edge): + def _edge_costs(self, edge, file_weight): # edge score times a weight # TODO: normalize node and edge scores to a specific range and # ordinality? edge_costs = (self.graph.edges[edge]['prediction_distance'] * self.parameters.weight_edge_score) + + if self.write_struct_svm: + file_weight.write("{} {}\n".format( + self.edge_selected[edge], + self.graph.edges[edge]['prediction_distance'])) + return edge_costs - def _add_constraints(self): + def _create_constraints(self): self.main_constraints = [] - self.pin_constraints = [] - self._add_pin_constraints() self._add_edge_constraints() self._add_cell_cycle_constraints() @@ -252,6 +379,9 @@ def _add_edge_constraints(self): logger.debug("setting edge constraints") + if self.write_struct_svm: + edge_constraint_file = open(f"constraints_edge_b{self.block_id}", 'w') + cnstr = "2*{} -1*{} -1*{} <= 0\n" for e in self.graph.edges(): # if e is selected, u and v have to be selected @@ -268,8 +398,14 @@ def _add_edge_constraints(self): constraint.set_value(0) self.main_constraints.append(constraint) + if self.write_struct_svm: + edge_constraint_file.write(cnstr.format(ind_e, ind_u, ind_v)) + logger.debug("set edge constraint %s", constraint) + if self.write_struct_svm: + edge_constraint_file.close() + def _add_inter_frame_constraints(self, t): '''Linking constraints from t to t+1.''' @@ -278,8 +414,9 @@ def _add_inter_frame_constraints(self, t): # Every selected node has exactly one selected edge to the previous and # one or two to the next frame. This includes the special "appear" and # "disappear" edges. + if self.write_struct_svm: + node_edge_constraint_file = open(f"constraints_node_edge_b{self.block_id}", 'a') for node in self.graph.cells_by_frame(t): - # we model this as three constraints: # sum(prev) - node = 0 # exactly one prev edge, # iff node selected @@ -290,12 +427,19 @@ def _add_inter_frame_constraints(self, t): constraint_next_1 = pylp.LinearConstraint() constraint_next_2 = pylp.LinearConstraint() + if self.write_struct_svm: + cnstr_prev = "" + cnstr_next_1 = "" + cnstr_next_2 = "" + # sum(prev) # all neighbors in previous frame pinned_to_1 = [] for edge in self.graph.prev_edges(node): constraint_prev.set_coefficient(self.edge_selected[edge], 1) + if self.write_struct_svm: + cnstr_prev += "1*{} ".format(self.edge_selected[edge]) if edge in self.pinned_edges and self.pinned_edges[edge]: pinned_to_1.append(edge) if len(pinned_to_1) > 1: @@ -304,42 +448,70 @@ def _add_inter_frame_constraints(self, t): % (node, pinned_to_1)) # plus "appear" constraint_prev.set_coefficient(self.node_appear[node], 1) + if self.write_struct_svm: + cnstr_prev += "1*{} ".format(self.node_appear[node]) # sum(next) for edge in self.graph.next_edges(node): constraint_next_1.set_coefficient(self.edge_selected[edge], 1) constraint_next_2.set_coefficient(self.edge_selected[edge], -1) + if self.write_struct_svm: + cnstr_next_1 += "1*{} ".format(self.edge_selected[edge]) + cnstr_next_2 += "-1*{} ".format(self.edge_selected[edge]) # plus "disappear" constraint_next_1.set_coefficient(self.node_disappear[node], 1) constraint_next_2.set_coefficient(self.node_disappear[node], -1) - + if self.write_struct_svm: + cnstr_next_1 += "1*{} ".format(self.node_disappear[node]) + cnstr_next_2 += "-1*{} ".format(self.node_disappear[node]) # node constraint_prev.set_coefficient(self.node_selected[node], -1) constraint_next_1.set_coefficient(self.node_selected[node], -2) constraint_next_2.set_coefficient(self.node_selected[node], 1) - + if self.write_struct_svm: + cnstr_prev += "-1*{} ".format(self.node_selected[node]) + cnstr_next_1 += "-2*{} ".format(self.node_selected[node]) + cnstr_next_2 += "1*{} ".format(self.node_selected[node]) # relation, value constraint_prev.set_relation(pylp.Relation.Equal) constraint_next_1.set_relation(pylp.Relation.LessEqual) constraint_next_2.set_relation(pylp.Relation.LessEqual) + if self.write_struct_svm: + cnstr_prev += " == " + cnstr_next_1 += " <= " + cnstr_next_2 += " <= " constraint_prev.set_value(0) constraint_next_1.set_value(0) constraint_next_2.set_value(0) + if self.write_struct_svm: + cnstr_prev += " 0\n" + cnstr_next_1 += " 0\n" + cnstr_next_2 += " 0\n" self.main_constraints.append(constraint_prev) self.main_constraints.append(constraint_next_1) self.main_constraints.append(constraint_next_2) + if self.write_struct_svm: + node_edge_constraint_file.write(cnstr_prev) + node_edge_constraint_file.write(cnstr_next_1) + node_edge_constraint_file.write(cnstr_next_2) + logger.debug( "set inter-frame constraints:\t%s\n\t%s\n\t%s", constraint_prev, constraint_next_1, constraint_next_2) + if self.write_struct_svm: + node_edge_constraint_file.close() + # Ensure that the split indicator is set for every cell that splits # into two daughter cells. + if self.write_struct_svm: + node_split_constraint_file = open(f"constraints_node_split_b{self.block_id}", 'a') for node in self.graph.cells_by_frame(t): # I.e., each node with two forwards edges is a split node. @@ -354,29 +526,51 @@ def _add_inter_frame_constraints(self, t): constraint_1 = pylp.LinearConstraint() constraint_2 = pylp.LinearConstraint() + if self.write_struct_svm: + cnstr_1 = "" + cnstr_2 = "" # sum(forward edges) for edge in self.graph.next_edges(node): constraint_1.set_coefficient(self.edge_selected[edge], 1) constraint_2.set_coefficient(self.edge_selected[edge], 1) + if self.write_struct_svm: + cnstr_1 += "1*{} ".format(self.edge_selected[edge]) + cnstr_2 += "1*{} ".format(self.edge_selected[edge]) # -[2*]split constraint_1.set_coefficient(self.node_split[node], -1) constraint_2.set_coefficient(self.node_split[node], -2) + if self.write_struct_svm: + cnstr_1 += "-1*{} ".format(self.node_split[node]) + cnstr_2 += "-2*{} ".format(self.node_split[node]) constraint_1.set_relation(pylp.Relation.LessEqual) constraint_2.set_relation(pylp.Relation.GreaterEqual) + if self.write_struct_svm: + cnstr_1 += " <= " + cnstr_2 += " >= " constraint_1.set_value(1) constraint_2.set_value(0) + if self.write_struct_svm: + cnstr_1 += " 1\n" + cnstr_2 += " 0\n" self.main_constraints.append(constraint_1) self.main_constraints.append(constraint_2) + if self.write_struct_svm: + node_split_constraint_file.write(cnstr_1) + node_split_constraint_file.write(cnstr_2) + logger.debug( "set split-indicator constraints:\n\t%s\n\t%s", constraint_1, constraint_2) + if self.write_struct_svm: + node_split_constraint_file.close() + def _add_cell_cycle_constraints(self): # If an edge is selected, the division and child indicators are # linked. Let e=(u,v) be an edge linking node u at time t + 1 to v in @@ -385,6 +579,8 @@ def _add_cell_cycle_constraints(self): # child(u) + selected(e) - split(v) <= 1 # split(v) + selected(e) - child(u) <= 1 + if self.write_struct_svm: + edge_split_constraint_file = open(f"constraints_edge_split_b{self.block_id}", 'a') for e in self.graph.edges(): # if e is selected, u and v have to be selected @@ -400,6 +596,15 @@ def _add_cell_cycle_constraints(self): link_constraint_1.set_relation(pylp.Relation.LessEqual) link_constraint_1.set_value(1) self.main_constraints.append(link_constraint_1) + if self.write_struct_svm: + link_cnstr_1 = "" + link_cnstr_1 += "1*{} ".format(child_u) + link_cnstr_1 += "1*{} ".format(ind_e) + link_cnstr_1 += "-1*{} ".format(split_v) + link_cnstr_1 += " <= " + link_cnstr_1 += " 1\n" + edge_split_constraint_file.write(link_cnstr_1) + link_constraint_2 = pylp.LinearConstraint() link_constraint_2.set_coefficient(split_v, 1) link_constraint_2.set_coefficient(ind_e, 1) @@ -407,12 +612,25 @@ def _add_cell_cycle_constraints(self): link_constraint_2.set_relation(pylp.Relation.LessEqual) link_constraint_2.set_value(1) self.main_constraints.append(link_constraint_2) + if self.write_struct_svm: + link_cnstr_2 = "" + link_cnstr_2 += "1*{} ".format(split_v) + link_cnstr_2 += "1*{} ".format(ind_e) + link_cnstr_2 += "-1*{} ".format(child_u) + link_cnstr_2 += " <= " + link_cnstr_2 += " 1\n" + edge_split_constraint_file.write(link_cnstr_2) + + if self.write_struct_svm: + edge_split_constraint_file.close() # Every selected node must be a split, child or continuation # (exclusively). If a node is not selected, all the cell cycle # indicators should be zero. # Constraint for each node: # split + child + continuation - selected = 0 + if self.write_struct_svm: + node_cell_cycle_constraint_file = open(f"constraints_node_cell_cycle_b{self.block_id}", 'a') for node in self.graph.nodes(): cycle_set_constraint = pylp.LinearConstraint() cycle_set_constraint.set_coefficient(self.node_split[node], 1) @@ -423,3 +641,15 @@ def _add_cell_cycle_constraints(self): cycle_set_constraint.set_relation(pylp.Relation.Equal) cycle_set_constraint.set_value(0) self.main_constraints.append(cycle_set_constraint) + if self.write_struct_svm: + cc_cnstr = "" + cc_cnstr += "1*{} ".format(self.node_split[node]) + cc_cnstr += "1*{} ".format(self.node_child[node]) + cc_cnstr += "1*{} ".format(self.node_continuation[node]) + cc_cnstr += "-1*{} ".format(self.node_selected[node]) + cc_cnstr += " == " + cc_cnstr += " 0\n" + node_cell_cycle_constraint_file.write(link_cnstr_2) + + if self.write_struct_svm: + node_cell_cycle_constraint_file.close() diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 15cc1e6..2c7acc8 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -7,7 +7,8 @@ logger = logging.getLogger(__name__) -def track(graph, config, selected_key, frame_key='t', frames=None) +def track(graph, config, selected_key, frame_key='t', frames=None, + block_id=None): ''' A wrapper function that takes a daisy subgraph and input parameters, creates and solves the ILP to create tracks, and updates the daisy subgraph to reflect the selected nodes and edges. @@ -41,20 +42,23 @@ def track(graph, config, selected_key, frame_key='t', frames=None) have nodes in all frames). Start is inclusive, end is exclusive. Defaults to graph.begin, graph.end - cell_cycle_key (``string``, optional): + block_id (``int``, optional): + + The ID of the current daisy block. - The name of the node attribute that corresponds to a prediction - about the cell cycle state. The prediction should be a list of - three values [mother/division, daughter, continuation]. ''' - if cell_cycle_key is not None: + # cell_cycle_keys = [p.cell_cycle_key for p in config.solve.parameters] + cell_cycle_keys = [p.cell_cycle_key + "mother" for p in config.solve.parameters] + if any(cell_cycle_keys): # remove nodes that don't have a cell cycle key, with warning to_remove = [] for node, data in graph.nodes(data=True): - if cell_cycle_key not in data: - logger.warning("Node %d does not have cell cycle key %s", - node, cell_cycle_key) - to_remove.append(node) + for key in cell_cycle_keys: + if key not in data: + logger.warning("Node %d does not have cell cycle key %s", + node, key) + to_remove.append(node) + break for node in to_remove: logger.debug("Removing node %d", node) @@ -84,8 +88,10 @@ def track(graph, config, selected_key, frame_key='t', frames=None) total_solve_time = 0 for parameter, key in zip(parameters, selected_key): if not solver: - solver = Solver(track_graph, parameter, key, frames=frames, - vgg_key=cell_cycle_key) + solver = Solver( + track_graph, parameter, key, frames=frames, + write_struct_svm=config.solve.write_struct_svm, + block_id=block_id) else: solver.update_objective(parameter, key) From f7aa2b4182f32729708b56dbad53f6bf1e6b045f Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 06:24:20 -0400 Subject: [PATCH 062/263] solv: add opt cell dens constr, add toggle to disable 0 cost at border of roi --- linajea/tracking/non_minimal_solver.py | 110 +++++++++++++++++++------ linajea/tracking/non_minimal_track.py | 4 +- linajea/tracking/solver.py | 101 ++++++++++++++++++++--- linajea/tracking/track.py | 4 +- 4 files changed, 179 insertions(+), 40 deletions(-) diff --git a/linajea/tracking/non_minimal_solver.py b/linajea/tracking/non_minimal_solver.py index dfc5e6d..58837f2 100644 --- a/linajea/tracking/non_minimal_solver.py +++ b/linajea/tracking/non_minimal_solver.py @@ -1,4 +1,4 @@ -# -*- coding: UTF-8 -*- +# -*- coding: utf-8 -*- import logging import pylp @@ -13,10 +13,14 @@ class NMSolver(object): we minimized the number of variables using assumptions about their relationships ''' - def __init__(self, track_graph, parameters, selected_key, frames=None): + def __init__(self, track_graph, parameters, selected_key, frames=None, + check_node_close_to_roi=True, + add_node_density_constraints=False): # frames: [start_frame, end_frame] where start_frame is inclusive # and end_frame is exclusive. Defaults to track_graph.begin, # track_graph.end + self.check_node_close_to_roi = check_node_close_to_roi + self.add_node_density_constraints = add_node_density_constraints self.graph = track_graph self.parameters = parameters @@ -38,9 +42,9 @@ def __init__(self, track_graph, parameters, selected_key, frames=None): self.solver = None self._create_indicators() - self._set_objective() - self._add_constraints() self._create_solver() + self._create_constraints() + self.update_objective(parameters, selected_key) def update_objective(self, parameters, selected_key): self.parameters = parameters @@ -62,11 +66,6 @@ def _create_solver(self): self.num_vars, pylp.VariableType.Binary, preference=pylp.Preference.Gurobi) - self.solver.set_objective(self.objective) - all_constraints = pylp.LinearConstraints() - for c in self.main_constraints + self.pin_constraints: - all_constraints.add(c) - self.solver.set_constraints(all_constraints) self.solver.set_num_threads(1) self.solver.set_timeout(120) @@ -132,17 +131,18 @@ def _set_objective(self): 0) # remove node appear and disappear costs at edge of roi - for node, data in self.graph.nodes(data=True): - if self._check_node_close_to_roi_edge( - node, - data, - self.parameters.max_cell_move): - objective.set_coefficient( - self.node_appear[node], - 0) - objective.set_coefficient( - self.node_disappear[node], - 0) + if self.check_node_close_to_roi: + for node, data in self.graph.nodes(data=True): + if self._check_node_close_to_roi_edge( + node, + data, + self.parameters.max_cell_move): + objective.set_coefficient( + self.node_appear[node], + 0) + objective.set_coefficient( + self.node_disappear[node], + 0) # node selection and split costs for node in self.graph.nodes: @@ -164,6 +164,9 @@ def _set_objective(self): def _check_node_close_to_roi_edge(self, node, data, distance): '''Return true if node is within distance to the z,y,x edge of the roi. Assumes 4D data with t,z,y,x''' + if isinstance(distance, dict): + distance = min(distance.values()) + begin = self.graph.roi.get_begin()[1:] end = self.graph.roi.get_end()[1:] for index, dim in enumerate(['z', 'y', 'x']): @@ -207,17 +210,21 @@ def _edge_costs(self, edge): return score_costs + prediction_distance_costs - def _add_constraints(self): + def _create_constraints(self): self.main_constraints = [] - self.pin_constraints = [] - self._add_pin_constraints() self._add_edge_constraints() + for t in range(self.graph.begin, self.graph.end): + self._add_cell_cycle_constraints(t) for t in range(self.graph.begin, self.graph.end): self._add_inter_frame_constraints(t) + if self.add_node_density_constraints: + self._add_node_density_constraints_objective() + + def _add_pin_constraints(self): for e in self.graph.edges(): @@ -265,7 +272,6 @@ def _add_inter_frame_constraints(self, t): # one or two to the next frame. This includes the special "appear" and # "disappear" edges. for node in self.graph.cells_by_frame(t): - # we model this as three constraints: # sum(prev) - node = 0 # exactly one prev edge, # iff node selected @@ -299,13 +305,11 @@ def _add_inter_frame_constraints(self, t): # plus "disappear" constraint_next_1.set_coefficient(self.node_disappear[node], 1) constraint_next_2.set_coefficient(self.node_disappear[node], -1) - # node constraint_prev.set_coefficient(self.node_selected[node], -1) constraint_next_1.set_coefficient(self.node_selected[node], -2) constraint_next_2.set_coefficient(self.node_selected[node], 1) - # relation, value constraint_prev.set_relation(pylp.Relation.Equal) @@ -362,3 +366,57 @@ def _add_inter_frame_constraints(self, t): logger.debug( "set split-indicator constraints:\n\t%s\n\t%s", constraint_1, constraint_2) + + def _add_node_density_constraints_objective(self): + from scipy.spatial import cKDTree + import numpy as np + try: + nodes_by_t = { + t: [ + ( + node, + np.array([data[d] for d in ['z', 'y', 'x']]), + ) + for node, data in self.graph.nodes(data=True) + if 't' in data and data['t'] == t + ] + for t in range(self.start_frame, self.end_frame) + } + except: + for node, data in self.graph.nodes(data=True): + print(node, data) + raise + + rad = 15 + dia = 2*rad + filter_sz = 1*dia + r = filter_sz/2 + radius = {30: 35, 60: 25, 100: 15, 1000:10} + for t in range(self.start_frame, self.end_frame): + kd_data = [pos for _, pos in nodes_by_t[t]] + kd_tree = cKDTree(kd_data) + + if isinstance(radius, dict): + for th, val in radius.items(): + if t < int(th): + r = val + break + nn_nodes = kd_tree.query_ball_point(kd_data, r, p=np.inf, + return_length=False) + + for idx, (node, _) in enumerate(nodes_by_t[t]): + if len(nn_nodes[idx]) == 1: + continue + constraint = pylp.LinearConstraint() + logger.debug("new constraint (frame %s) node pos %s", + t, kd_data[idx]) + for nn_id in nn_nodes[idx]: + if nn_id == idx: + continue + nn = nodes_by_t[t][nn_id][0] + constraint.set_coefficient(self.node_selected[nn], 1) + logger.debug(" neighbor pos %s %s", kd_data[nn_id], np.linalg.norm(np.array(kd_data[idx])-np.array(kd_data[nn_id]), ord=np.inf)) + constraint.set_coefficient(self.node_selected[node], 1) + constraint.set_relation(pylp.Relation.LessEqual) + constraint.set_value(1) + self.main_constraints.append(constraint) diff --git a/linajea/tracking/non_minimal_track.py b/linajea/tracking/non_minimal_track.py index e01cad2..f688fbc 100644 --- a/linajea/tracking/non_minimal_track.py +++ b/linajea/tracking/non_minimal_track.py @@ -66,7 +66,9 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): for parameter, key in zip(parameters, selected_key): if not solver: solver = NMSolver( - track_graph, parameter, key, frames=frames) + track_graph, parameter, key, frames=frames, + check_node_close_to_roi=config.solve.check_node_close_to_roi, + add_node_density_constraints=config.solve.add_node_density_constraints) else: solver.update_objective(parameter, key) diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index 893ac56..b9b40dc 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -13,11 +13,15 @@ class Solver(object): number of hyperparamters ''' def __init__(self, track_graph, parameters, selected_key, frames=None, - write_struct_svm=False, block_id=None) + write_struct_svm=False, block_id=None, + check_node_close_to_roi=True, + add_node_density_constraints=False): # frames: [start_frame, end_frame] where start_frame is inclusive # and end_frame is exclusive. Defaults to track_graph.begin, # track_graph.end self.write_struct_svm = write_struct_svm + self.check_node_close_to_roi = check_node_close_to_roi + self.add_node_density_constraints = add_node_density_constraints self.block_id = block_id self.graph = track_graph @@ -216,17 +220,18 @@ def _set_objective(self): disappear_file.write("{} 0\n".format(self.node_disappear[node])) # remove node appear costs at edge of roi - for node, data in self.graph.nodes(data=True): - if self._check_node_close_to_roi_edge( - node, - data, - self.parameters.max_cell_move): - objective.set_coefficient( - self.node_appear[node], - 0) - if self.write_struct_svm: - appear_file.write("{} 0\n".format( - self.node_appear[node])) + if self.check_node_close_to_roi: + for node, data in self.graph.nodes(data=True): + if self._check_node_close_to_roi_edge( + node, + data, + self.parameters.max_cell_move): + objective.set_coefficient( + self.node_appear[node], + 0) + if self.write_struct_svm: + appear_file.write("{} 0\n".format( + self.node_appear[node])) if self.write_struct_svm: appear_file.close() @@ -359,6 +364,10 @@ def _create_constraints(self): for t in range(self.graph.begin, self.graph.end): self._add_inter_frame_constraints(t) + if self.add_node_density_constraints: + self._add_node_density_constraints_objective() + + def _add_pin_constraints(self): for e in self.graph.edges(): @@ -653,3 +662,71 @@ def _add_cell_cycle_constraints(self): if self.write_struct_svm: node_cell_cycle_constraint_file.close() + + def _add_node_density_constraints_objective(self): + from scipy.spatial import cKDTree + import numpy as np + try: + nodes_by_t = { + t: [ + ( + node, + np.array([data[d] for d in ['z', 'y', 'x']]), + ) + for node, data in self.graph.nodes(data=True) + if 't' in data and data['t'] == t + ] + for t in range(self.start_frame, self.end_frame) + } + except: + for node, data in self.graph.nodes(data=True): + print(node, data) + raise + + rad = 15 + dia = 2*rad + filter_sz = 1*dia + r = filter_sz/2 + radius = {30: 35, 60: 25, 100: 15, 1000:10} + if self.write_struct_svm: + node_density_constraint_file = open(f"constraints_node_density_b{self.block_id}", 'w') + for t in range(self.start_frame, self.end_frame): + kd_data = [pos for _, pos in nodes_by_t[t]] + kd_tree = cKDTree(kd_data) + + if isinstance(radius, dict): + for th, val in radius.items(): + if t < int(th): + r = val + break + nn_nodes = kd_tree.query_ball_point(kd_data, r, p=np.inf, + return_length=False) + + for idx, (node, _) in enumerate(nodes_by_t[t]): + if len(nn_nodes[idx]) == 1: + continue + constraint = pylp.LinearConstraint() + if self.write_struct_svm: + cnstr = "" + logger.debug("new constraint (frame %s) node pos %s", + t, kd_data[idx]) + for nn_id in nn_nodes[idx]: + if nn_id == idx: + continue + nn = nodes_by_t[t][nn_id][0] + constraint.set_coefficient(self.node_selected[nn], 1) + if self.write_struct_svm: + cnstr += "1*{} ".format(self.node_selected[nn]) + logger.debug(" neighbor pos %s %s", kd_data[nn_id], np.linalg.norm(np.array(kd_data[idx])-np.array(kd_data[nn_id]), ord=np.inf)) + constraint.set_coefficient(self.node_selected[node], 1) + constraint.set_relation(pylp.Relation.LessEqual) + constraint.set_value(1) + self.main_constraints.append(constraint) + if self.write_struct_svm: + cnstr += "1*{} ".format(self.node_selected[node]) + cnstr += " <= " + cnstr += " 1\n" + node_density_constraint_file.write(cnstr) + + if self.write_struct_svm: + node_density_constraint_file.close() diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 2c7acc8..ae0fb14 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -91,7 +91,9 @@ def track(graph, config, selected_key, frame_key='t', frames=None, solver = Solver( track_graph, parameter, key, frames=frames, write_struct_svm=config.solve.write_struct_svm, - block_id=block_id) + block_id=block_id, + check_node_close_to_roi=config.solve.check_node_close_to_roi, + add_node_density_constraints=config.solve.add_node_density_constraints) else: solver.update_objective(parameter, key) From 6bfa51342bcaa3a638e2fe1248ee6d6a4792033b Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 06:26:12 -0400 Subject: [PATCH 063/263] solve: add cell cycle to non_minimal solver --- linajea/tracking/non_minimal_solver.py | 161 +++++++++++++++++++++---- 1 file changed, 136 insertions(+), 25 deletions(-) diff --git a/linajea/tracking/non_minimal_solver.py b/linajea/tracking/non_minimal_solver.py index 58837f2..a9cfd73 100644 --- a/linajea/tracking/non_minimal_solver.py +++ b/linajea/tracking/non_minimal_solver.py @@ -33,8 +33,13 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, self.node_appear = {} self.node_disappear = {} self.node_split = {} + self.node_child = {} + self.node_continuation = {} self.pinned_edges = {} + if self.parameters.use_cell_state: + self.edge_split = {} + self.num_vars = None self.objective = None self.main_constraints = [] # list of LinearConstraint objects @@ -47,6 +52,8 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, self.update_objective(parameters, selected_key) def update_objective(self, parameters, selected_key): + assert self.parameters.use_cell_state == parameters.use_cell_state, \ + "cannot switch between w/ and w/o cell cycle within one run" self.parameters = parameters self.selected_key = selected_key @@ -96,18 +103,49 @@ def _create_indicators(self): self.node_appear[node] = self.num_vars + 1 self.node_disappear[node] = self.num_vars + 2 self.node_split[node] = self.num_vars + 3 - self.num_vars += 4 + self.node_child[node] = self.num_vars + 4 + self.node_continuation[node] = self.num_vars + 5 + self.num_vars += 6 for edge in self.graph.edges(): self.edge_selected[edge] = self.num_vars self.num_vars += 1 + if self.parameters.use_cell_state: + self.edge_split[edge] = self.num_vars + self.num_vars += 1 + def _set_objective(self): logger.debug("setting objective") objective = pylp.LinearObjective(self.num_vars) + # node selection and split costs + for node in self.graph.nodes: + objective.set_coefficient( + self.node_selected[node], + self._node_costs(node)) + objective.set_coefficient( + self.node_split[node], + self._split_costs(node)) + objective.set_coefficient( + self.node_child[node], + self._child_costs(node)) + objective.set_coefficient( + self.node_continuation[node], + self._continuation_costs(node)) + + # edge selection costs + for edge in self.graph.edges(): + objective.set_coefficient( + self.edge_selected[edge], + self._edge_costs(edge)) + + if self.parameters.use_cell_state: + objective.set_coefficient( + self.edge_split[edge], 0) + # node appear (skip first frame) for t in range(self.start_frame + 1, self.end_frame): for node in self.graph.cells_by_frame(t): @@ -144,21 +182,6 @@ def _set_objective(self): self.node_disappear[node], 0) - # node selection and split costs - for node in self.graph.nodes: - objective.set_coefficient( - self.node_selected[node], - self._node_costs(node)) - objective.set_coefficient( - self.node_split[node], - self.parameters.cost_split) - - # edge selection costs - for edge in self.graph.edges(): - objective.set_coefficient( - self.edge_selected[edge], - self._edge_costs(edge)) - self.objective = objective def _check_node_close_to_roi_edge(self, node, data, distance): @@ -187,15 +210,70 @@ def _check_node_close_to_roi_edge(self, node, data, distance): return False def _node_costs(self, node): - - # simple linear costs based on the score of a node (negative if above - # threshold_node_score, positive otherwise) - - score_costs = ( - self.parameters.threshold_node_score - - self.graph.nodes[node]['score']) - - return score_costs*self.parameters.weight_node_score + # node score times a weight plus a threshold + score_costs = ((self.parameters.threshold_node_score - + self.graph.nodes[node]['score']) * + self.parameters.weight_node_score) + + return score_costs + + def _split_costs(self, node): + if not self.parameters.use_cell_state: + return self.parameters.cost_split + elif self.parameters.use_cell_state == 'simple' or \ + self.parameters.use_cell_state == 'v1' or \ + self.parameters.use_cell_state == 'v2': + return ((self.parameters.threshold_split_score - + self.graph.nodes[node][self.parameters.prefix+'mother']) * + self.parameters.cost_split) + elif self.parameters.use_cell_state == 'v3' or \ + self.parameters.use_cell_state == 'v4': + if self.graph.nodes[node][self.parameters.prefix+'mother'] > \ + self.parameters.threshold_split_score: + return -self.parameters.cost_split + else: + return self.parameters.cost_split + else: + raise NotImplementedError("invalid value for use_cell_state") + + def _child_costs(self, node): + if not self.parameters.use_cell_state: + return 0 + elif self.parameters.use_cell_state == 'v1' or \ + self.parameters.use_cell_state == 'v2': + # TODO cost_split -> cost_daughter + return ((self.parameters.threshold_split_score - + self.graph.nodes[node][self.parameters.prefix+'daughter']) * + self.parameters.cost_split) + elif self.parameters.use_cell_state == 'v3' or \ + self.parameters.use_cell_state == 'v4': + if self.graph.nodes[node][self.parameters.prefix+'daughter'] > \ + self.parameters.threshold_split_score: + return -self.parameters.cost_daughter + else: + return self.parameters.cost_daughter + else: + raise NotImplementedError("invalid value for use_cell_state") + + def _continuation_costs(self, node): + if not self.parameters.use_cell_state or \ + self.parameters.use_cell_state == 'v1' or \ + self.parameters.use_cell_state == 'v3': + return 0 + elif self.parameters.use_cell_state == 'v2': + # TODO cost_split -> cost_normal + return ((self.parameters.threshold_is_normal_score - + self.graph.nodes[node][self.parameters.prefix+'normal']) * + self.parameters.cost_split) + elif self.parameters.use_cell_state == 'v4': + if self.graph.nodes[node][self.parameters.prefix+'normal'] > \ + self.parameters.threshold_is_normal_score: + # return 0 + return -self.parameters.cost_normal + else: + return self.parameters.cost_normal + else: + raise NotImplementedError("invalid value for use_cell_state") def _edge_costs(self, edge): @@ -367,6 +445,39 @@ def _add_inter_frame_constraints(self, t): "set split-indicator constraints:\n\t%s\n\t%s", constraint_1, constraint_2) + def _add_cell_cycle_constraints(self, t): + for node in self.graph.cells_by_frame(t): + if self.parameters.use_cell_state: + # sum(next(edges_split))- 2*split >= 0 + constraint_3 = pylp.LinearConstraint() + for edge in self.graph.next_edges(node): + constraint_3.set_coefficient(self.edge_split[edge], 1) + constraint_3.set_coefficient(self.node_split[node], -2) + constraint_3.set_relation(pylp.Relation.Equal) + constraint_3.set_value(0) + + self.main_constraints.append(constraint_3) + + constraint_4 = pylp.LinearConstraint() + for edge in self.graph.prev_edges(node): + constraint_4.set_coefficient(self.edge_split[edge], 1) + constraint_4.set_coefficient(self.node_child[node], -1) + constraint_4.set_relation(pylp.Relation.Equal) + constraint_4.set_value(0) + + self.main_constraints.append(constraint_4) + + if self.parameters.use_cell_state == 'v2' or \ + self.parameters.use_cell_state == 'v4': + constraint_6 = pylp.LinearConstraint() + constraint_6.set_coefficient(self.node_selected[node], -1) + constraint_6.set_coefficient(self.node_split[node], 1) + constraint_6.set_coefficient(self.node_child[node], 1) + constraint_6.set_coefficient(self.node_continuation[node], 1) + constraint_6.set_relation(pylp.Relation.Equal) + constraint_6.set_value(0) + self.main_constraints.append(constraint_6) + def _add_node_density_constraints_objective(self): from scipy.spatial import cKDTree import numpy as np From 8b5c7d1a31b4554d24e27a9543734e4f9322a514 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 06:26:49 -0400 Subject: [PATCH 064/263] update required gunpowder version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7c95327..a3f7fa8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,5 +13,5 @@ sklearn -e git+https://github.com/funkelab/funlib.run#egg=funlib.run -e git+https://github.com/funkelab/funlib.learn.torch#egg=funlib.learn.torch -e git+https://github.com/funkelab/funlib.learn.tensorflow#egg=funlib.learn.tensorflow --e git+https://github.com/funkey/gunpowder@b7bd287e82553fa6d1d667a474e3e5457577060e#egg=gunpowder +-e git+https://github.com/funkey/gunpowder@773b5578b01d16d34ef3cc466904ab876bba8bf1#egg=gunpowder -e git+https://github.com/funkelab/daisy@3d7826e7e4ab5844d55debac9bbb00b4e43a998b#egg=daisy From 8ac90ceeb887be516b5483fccac888d2e2196c89 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 10:52:26 -0400 Subject: [PATCH 065/263] vis: add script to export to cell track chall format --- linajea/visualization/ctc/__init__.py | 1 + linajea/visualization/ctc/write_ctc.py | 213 +++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 linajea/visualization/ctc/__init__.py create mode 100644 linajea/visualization/ctc/write_ctc.py diff --git a/linajea/visualization/ctc/__init__.py b/linajea/visualization/ctc/__init__.py new file mode 100644 index 0000000..b7e38c4 --- /dev/null +++ b/linajea/visualization/ctc/__init__.py @@ -0,0 +1 @@ +from .write_ctc import write_ctc diff --git a/linajea/visualization/ctc/write_ctc.py b/linajea/visualization/ctc/write_ctc.py new file mode 100644 index 0000000..804d8b2 --- /dev/null +++ b/linajea/visualization/ctc/write_ctc.py @@ -0,0 +1,213 @@ +import logging +import os + +import numpy as np +import raster_geometry as rg +import tifffile + +logger = logging.getLogger(__name__) + +logging.basicConfig(level=20) + +def write_ctc(graph, start_frame, end_frame, shape, + out_dir, txt_fn, tif_fn, paint_sphere=False, + voxel_size=None, gt=False): + os.makedirs(out_dir, exist_ok=True) + + logger.info("writing frames %s,%s (graph %s)", + start_frame, end_frame, graph.get_frames()) + # assert start_frame >= graph.get_frames()[0] + if end_frame is None: + end_frame = graph.get_frames()[1] + # else: + # assert end_frame <= graph.get_frames()[1] + track_cntr = 1 + + node_to_track = {} + curr_cells = set(graph.cells_by_frame(start_frame)) + for c in curr_cells: + node_to_track[c] = (track_cntr, 0, start_frame) + track_cntr += 1 + prev_cells = curr_cells + + cells_by_t_data = {} + for f in range(start_frame+1, end_frame+1): + cells_by_t_data[f] = [] + curr_cells = set(graph.cells_by_frame(f)) + for c in prev_cells: + edges = list(graph.next_edges(c)) + assert len(edges) <= 2, "more than two children" + if len(edges) == 1: + e = edges[0] + assert e[0] in curr_cells, "cell missing" + assert e[1] in prev_cells, "cell missing" + node_to_track[e[0]] = ( + node_to_track[e[1]][0], + node_to_track[e[1]][1], + f) + curr_cells.remove(e[0]) + elif len(edges) == 2: + for e in edges: + assert e[0] in curr_cells, "cell missing" + assert e[1] in prev_cells, "cell missing" + node_to_track[e[0]] = ( + track_cntr, + node_to_track[e[1]][0], + f) + track_cntr += 1 + curr_cells.remove(e[0]) + + if gt: + for e in edges: + st = e[1] + nd = e[0] + dataSt = graph.nodes(data=True)[st] + dataNd = graph.nodes(data=True)[nd] + cells_by_t_data[f].append(( + nd, + np.array([dataNd[d] + for d in ['z', 'y', 'x']]), + np.array([dataSt[d] - dataNd[d] + for d in ['z', 'y', 'x']]))) + for c in curr_cells: + node_to_track[c] = (track_cntr, 0, f) + track_cntr += 1 + prev_cells = set(graph.cells_by_frame(f)) + + tracks = {} + for c, v in node_to_track.items(): + if v[0] in tracks: + tracks[v[0]][1].append(v[2]) + else: + tracks[v[0]] = (v[1], [v[2]]) + + if not gt: + cells_by_t_data = { + t: [ + ( + cell, + np.array([data[d] for d in ['z', 'y', 'x']]), + np.array(data['parent_vector']) + ) + for cell, data in graph.nodes(data=True) + if 't' in data and data['t'] == t + ] + for t in range(start_frame, end_frame+1) + } + with open(os.path.join(out_dir, "parent_vectors.txt"), 'w') as of: + for t, cs in cells_by_t_data.items(): + for c in cs: + of.write("{} {} {} {} {} {} {}\n".format( + t, c[1][0], c[1][1], c[1][2], + c[2][0], c[2][1], c[2][2])) + + with open(os.path.join(out_dir, txt_fn), 'w') as of: + for t, v in tracks.items(): + logger.debug("{} {} {} {}".format( + t, min(v[1]), max(v[1]), v[0])) + of.write("{} {} {} {}\n".format( + t, min(v[1]), max(v[1]), v[0])) + + + if paint_sphere: + spheres = {} + radii = {30:35, + 60:25, + 100:15, + 1000:11, + } + radii = {30:71, + 60:51, + 100:31, + 1000:21, + } + + for th, r in radii.items(): + sphere_shape = (max(3, r//voxel_size[1]+1), r, r) + zh = sphere_shape[0]//2 + yh = sphere_shape[1]//2 + xh = sphere_shape[2]//2 + sphere_rad = (sphere_shape[0]/2, + sphere_shape[1]/2, + sphere_shape[2]/2) + sphere = rg.ellipsoid(sphere_shape, sphere_rad) + spheres[th] = [sphere, zh, yh, xh] + # print(sphere) + # print(shape, sphere.shape) + for f in range(start_frame, end_frame+1): + arr = np.zeros(shape[1:], dtype=np.uint16) + for c, v in node_to_track.items(): + if f != v[2]: + continue + t = graph.nodes[c]['t'] + z = int(graph.nodes[c]['z']/voxel_size[1]) + y = int(graph.nodes[c]['y']/voxel_size[2]) + x = int(graph.nodes[c]['x']/voxel_size[3]) + print(v[0], f, t, z, y, x, c, v) + if paint_sphere: + if isinstance(spheres, dict): + for th in sorted(spheres.keys()): + if t < int(th): + sphere, zh, yh, xh = spheres[th] + break + try: + arr[(z-zh):(z+zh+1), + (y-yh):(y+yh+1), + (x-xh):(x+xh+1)] = sphere * v[0] + except ValueError as e: + print(e) + print(z, zh, y, yh, x, xh, sphere.shape) + print(z-zh, z+zh+1, y-yh, y+yh+1, x-xh, x+xh+1, sphere.shape) + sphereT = np.copy(sphere) + if z-zh < 0: + zz1 = 0 + sphereT = sphereT[(-(z-zh)):,...] + print(sphereT.shape) + else: + zz1 = z-zh + if z+zh+1 >= arr.shape[0]: + zz2 = arr.shape[0] + zt = arr.shape[0] - (z+zh+1) + sphereT = sphereT[:zt,...] + print(sphereT.shape) + else: + zz2 = z+zh+1 + + if y-yh < 0: + yy1 = 0 + sphereT = sphereT[:, (-(y - yh)) :, ...] + print(sphereT.shape) + else: + yy1 = y-yh + if y+yh+1 >= arr.shape[1]: + yy2 = arr.shape[1] + yt = arr.shape[1] - (y+yh+1) + sphereT = sphereT[:,:yt,...] + print(sphereT.shape) + else: + yy2 = y+yh+1 + + if x-xh < 0: + xx1 = 0 + sphereT = sphereT[...,(-(x-xh)):] + print(sphereT.shape) + else: + xx1 = x-xh + if x+xh+1 >= arr.shape[2]: + xx2 = arr.shape[2] + xt = arr.shape[2] - (x+xh+1) + sphereT = sphereT[...,:xt] + print(sphereT.shape) + else: + xx2 = x+xh+1 + + print(zz1, zz2, yy1, yy2, xx1, xx2) + arr[zz1:zz2, + yy1:yy2, + xx1:xx2] = sphereT * v[0] + # raise e + else: + arr[z, y, x] = v[0] + tifffile.imwrite(os.path.join( + out_dir, tif_fn.format(f)), arr, + compress=3) From 5a846fb5ea8273703b21bb2f8cc693c11e957c01 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 10:55:05 -0400 Subject: [PATCH 066/263] vis: mamut: fix return val, set edge score, data seems to be pair --- .../mamut/mamut_matched_tracks_reader.py | 57 ++----------------- linajea/visualization/mamut/mamut_writer.py | 3 +- .../mamut/mamut_xml_templates.py | 2 +- 3 files changed, 7 insertions(+), 55 deletions(-) diff --git a/linajea/visualization/mamut/mamut_matched_tracks_reader.py b/linajea/visualization/mamut/mamut_matched_tracks_reader.py index adafc42..0ccc744 100644 --- a/linajea/visualization/mamut/mamut_matched_tracks_reader.py +++ b/linajea/visualization/mamut/mamut_matched_tracks_reader.py @@ -13,57 +13,8 @@ def __init__(self, db_host): super(MamutReader, self).__init__() self.db_host = db_host - def read_data(self, data): - candidate_db_name = data['db_name'] - start_frame, end_frame = data['frames'] - matching_threshold = data.get('matching_threshold', 20) - gt_db_name = data['gt_db_name'] - assert end_frame > start_frame - roi = Roi((start_frame, 0, 0, 0), - (end_frame - start_frame, 1e10, 1e10, 1e10)) - if 'parameters_id' in data: - try: - int(data['parameters_id']) - selected_key = 'selected_' + str(data['parameters_id']) - except: - selected_key = data['parameters_id'] - else: - selected_key = None - db = linajea.CandidateDatabase( - candidate_db_name, self.db_host) - db.selected_key = selected_key - gt_db = linajea.CandidateDatabase(gt_db_name, self.db_host) - - print("Reading GT cells and edges in %s" % roi) - gt_subgraph = gt_db[roi] - gt_graph = linajea.tracking.TrackGraph(gt_subgraph, frame_key='t') - gt_tracks = list(gt_graph.get_tracks()) - print("Found %d GT tracks" % len(gt_tracks)) - - # tracks_to_xml(gt_cells, gt_tracks, 'linajea_gt.xml') - - print("Reading cells and edges in %s" % roi) - subgraph = db.get_selected_graph(roi) - graph = linajea.tracking.TrackGraph(subgraph, frame_key='t') - tracks = list(graph.get_tracks()) - print("Found %d tracks" % len(tracks)) - - if len(graph.nodes) == 0 or len(gt_graph.nodes) == 0: - logger.info("Didn't find gt or reconstruction - returning") - return [], [] - - m = linajea.evaluation.match_edges( - gt_graph, graph, - matching_threshold=matching_threshold) - (edges_x, edges_y, edge_matches, edge_fps) = m - matched_rec_tracks = [] - for track in tracks: - for _, edge_index in edge_matches: - edge = edges_y[edge_index] - if track.has_edge(edge[0], edge[1]): - matched_rec_tracks.append(track) - break - logger.debug("found %d matched rec tracks" % len(matched_rec_tracks)) + def read_data(self, data, is_tp=None): + (gt_tracks, matched_rec_tracks) = data logger.info("Adding %d gt tracks" % len(gt_tracks)) track_id = 0 @@ -94,11 +45,11 @@ def read_data(self, data): def add_track(self, track, track_id, group): if len(track.nodes) == 0: logger.info("Track has no nodes. Skipping") - return None + return [], {} if len(track.edges) == 0: logger.info("Track has no edges. Skipping") - return None + return [], {} cells = [] invalid_cells = [] diff --git a/linajea/visualization/mamut/mamut_writer.py b/linajea/visualization/mamut/mamut_writer.py index dfc8f04..f60bc66 100644 --- a/linajea/visualization/mamut/mamut_writer.py +++ b/linajea/visualization/mamut/mamut_writer.py @@ -1,6 +1,7 @@ from __future__ import print_function, division, absolute_import import os import logging + from .mamut_xml_templates import ( begin_template, alltracks_template, @@ -78,7 +79,7 @@ def write(self, raw_data_xml, output_xml, scale=1.0): output.write(begin_template) - self.cells_to_xml(output, scale) + self.cells_to_xml(output, scale=scale) # Begin AllTracks. output.write(alltracks_template) diff --git a/linajea/visualization/mamut/mamut_xml_templates.py b/linajea/visualization/mamut/mamut_xml_templates.py index 99159f9..5e9b1fa 100755 --- a/linajea/visualization/mamut/mamut_xml_templates.py +++ b/linajea/visualization/mamut/mamut_xml_templates.py @@ -60,7 +60,7 @@ # Templates for tracks and edges. alltracks_template = ' \n' track_template = ' \n' -edge_template = ' \n' +edge_template = ' \n' # edge_template = ' \n' track_end_template = ' \n' alltracks_end_template = ' \n' From 88c3f3ef8ee62c9515b2b4f6ae9357fb92242e49 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 11:06:37 -0400 Subject: [PATCH 067/263] solve/eval: small logging changes --- linajea/evaluation/evaluate_setup.py | 10 +++++++--- linajea/process_blockwise/solve_blockwise.py | 18 +++++++++--------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index 24506ae..2ad3803 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -1,4 +1,5 @@ import logging +import os import sys import time @@ -12,7 +13,8 @@ def evaluate_setup(linajea_config): - assert len(linajea_config.solve.parameters) == 1, "can only handle single parameter set" + assert len(linajea_config.solve.parameters) == 1, \ + "can only handle single parameter set" parameters = linajea_config.solve.parameters[0] data = linajea_config.inference.data_source @@ -31,9 +33,11 @@ def evaluate_setup(linajea_config): if old_score: logger.info("Already evaluated %d (%s). Skipping" % (parameters_id, linajea_config.evaluate.parameters)) - return old_score + logger.info("Stored results: %s", old_score) + return - logger.info("Evaluating in %s", evaluate_roi) + logger.info("Evaluating %s in %s", + os.path.basename(data.datafile.filename), evaluate_roi) edges_db = linajea.CandidateDatabase(db_name, db_host, parameters_id=parameters_id) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index 22f8edd..dee0657 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -165,12 +165,12 @@ def solve_in_block(linajea_config, num_nodes = graph.number_of_nodes() num_edges = graph.number_of_edges() - logger.info("Reading graph with %d nodes and %d edges took %s seconds" - % (num_nodes, num_edges, time.time() - start_time)) + logger.info("Reading graph with %d nodes and %d edges took %s seconds", + num_nodes, num_edges, time.time() - start_time) if num_edges == 0: - logger.info("No edges in roi %s. Skipping" - % read_roi) + logger.info("No edges in roi %s. Skipping", + read_roi) write_done(block, step_name, db_name, db_host) return 0 @@ -183,11 +183,11 @@ def solve_in_block(linajea_config, frames=frames, block_id=block.block_id) start_time = time.time() graph.update_edge_attrs( - write_roi, + roi=write_roi, attributes=selected_keys) - logger.info("Updating %d keys for %d edges took %s seconds" - % (len(selected_keys), - num_edges, - time.time() - start_time)) + logger.info("Updating %d keys for %d edges took %s seconds", + len(selected_keys), + num_edges, + time.time() - start_time) write_done(block, step_name, db_name, db_host) return 0 From 42b0d70ed685a88844494d9cc45457cc10f965ea Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 11:10:35 -0400 Subject: [PATCH 068/263] parse tracks: cast radius to float if exists --- linajea/parse_tracks_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linajea/parse_tracks_file.py b/linajea/parse_tracks_file.py index 8963302..40756bf 100644 --- a/linajea/parse_tracks_file.py +++ b/linajea/parse_tracks_file.py @@ -100,7 +100,7 @@ def _parse_csv_fields(filename, scale=1.0, limit_to_roi=None): track_info.append([int(row['cell_id']), int(row['parent_id']), int(row['track_id']), - [row.get("radius"), + [float(row['radius']) if 'radius' in row else None, row.get("name")]]) if 'div_state' in row: track_info[-1].append(int(row['div_state'])) From 39ce3ed56e12709387a49f2c09c121277e05c955 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 12:31:15 -0400 Subject: [PATCH 069/263] update linajea gp nodes for new gp version --- linajea/gunpowder/add_parent_vectors.py | 48 ++++++++++++++----------- linajea/gunpowder/shift_augment.py | 37 +++++++++++-------- linajea/gunpowder/tracks_source.py | 1 - 3 files changed, 49 insertions(+), 37 deletions(-) diff --git a/linajea/gunpowder/add_parent_vectors.py b/linajea/gunpowder/add_parent_vectors.py index 2b70298..f677098 100644 --- a/linajea/gunpowder/add_parent_vectors.py +++ b/linajea/gunpowder/add_parent_vectors.py @@ -3,7 +3,7 @@ from gunpowder.array_spec import ArraySpec from gunpowder.coordinate import Coordinate from gunpowder.morphology import enlarge_binary_map -from gunpowder.points_spec import PointsSpec +from gunpowder.graph_spec import GraphSpec from gunpowder.roi import Roi import logging import numpy as np @@ -64,6 +64,7 @@ def prepare(self, request): for i in range(1, len(context)): context[i] = max(context[i], self.move_radius) + logger.debug ("parent vector context %s", context) # request points in a larger area points_roi = request[self.array].roi.grow( Coordinate(context), @@ -72,11 +73,11 @@ def prepare(self, request): # however, restrict the request to the points actually provided points_roi = points_roi.intersect(self.spec[self.points].roi) logger.debug("Requesting points in roi %s" % points_roi) - request[self.points] = PointsSpec(roi=points_roi) + request[self.points] = GraphSpec(roi=points_roi) def process(self, batch, request): - points = batch.points[self.points] + points = batch.graphs[self.points] voxel_size = self.spec[self.array].voxel_size # get roi used for creating the new array (points_roi does not @@ -91,10 +92,11 @@ def process(self, batch, request): data_roi = data_roi.grow((-1, 0, 0, 0), (-1, 0, 0, 0)) logger.debug("Points in %s", points.spec.roi) - for i, point in points.data.items(): - logger.debug("%d, %s", i, point.location) - logger.debug("Data roi in voxels: %s", data_roi) - logger.debug("Data roi in world units: %s", data_roi*voxel_size) + if logger.isEnabledFor(logging.DEBUG): + for node in points.nodes: + logger.debug("%d, %s", node.id, node.location) + logger.debug("Data roi in voxels: %s", data_roi) + logger.debug("Data roi in world units: %s", data_roi*voxel_size) parent_vectors_data, mask_data = self.__draw_parent_vectors( points, @@ -125,11 +127,11 @@ def process(self, batch, request): if self.points in request: request_roi = request[self.points].roi points.spec.roi = request_roi - for i, p in list(points.data.items()): - if not request_roi.contains(p.location): - del points.data[i] + for point in list(points.nodes): + if not request_roi.contains(point.location): + points.remove_node(point) - if len(points.data) == 0: + if points.num_vertices() == 0: logger.warning("Returning empty batch for key %s and roi %s" % (self.points, request_roi)) @@ -166,12 +168,12 @@ def __draw_parent_vectors( logger.debug( "Adding parent vectors for %d points...", - len(points.data)) + points.num_vertices()) empty = True cnt = 0 total = 0 - for point_id, point in points.data.items(): + for point in points.nodes: # get the voxel coordinate, 'Coordinate' ensures integer v = Coordinate(point.location/voxel_size) @@ -183,15 +185,15 @@ def __draw_parent_vectors( continue total += 1 - if point.parent_id is None: + if point.attrs.get("parent_id") is None: logger.warning("Skipping point without parent") continue - if point.parent_id not in points.data: + if not points.contains(point.attrs["parent_id"]): logger.warning( "parent %d of %d not in %s", - point.parent_id, - point_id, self.points) + point.attrs["parent_id"], + point.id, self.points) logger.debug("request roi: %s" % data_roi) if not self.dense: continue @@ -207,18 +209,22 @@ def __draw_parent_vectors( point_mask = np.zeros(shape, dtype=np.bool) point_mask[v] = 1 + r = radius + if point.attrs.get('value') is not None: + r = point.attrs['value'][0] + enlarge_binary_map( point_mask, - radius, + r, voxel_size, in_place=True) mask = np.logical_or(mask, point_mask) - if point.parent_id not in points.data and self.dense: + if not points.contains(point.attrs["parent_id"]) and self.dense: continue cnt += 1 - parent = points.data[point.parent_id] + parent = points.node(point.attrs["parent_id"]) parent_vectors[0][point_mask] = (parent.location[1] - coords[0][point_mask]) @@ -229,7 +235,7 @@ def __draw_parent_vectors( if empty: logger.warning("No parent vectors written for points %s" - % points.data) + % points.nodes) logger.info("written {}/{}".format(cnt, total)) return parent_vectors, mask.astype(np.float32) diff --git a/linajea/gunpowder/shift_augment.py b/linajea/gunpowder/shift_augment.py index 204d8f0..5d79f6b 100644 --- a/linajea/gunpowder/shift_augment.py +++ b/linajea/gunpowder/shift_augment.py @@ -4,6 +4,7 @@ import random from gunpowder.roi import Roi from gunpowder.coordinate import Coordinate +from gunpowder.batch_request import BatchRequest from gunpowder import BatchFilter @@ -60,6 +61,8 @@ def __init__( self.lcm_voxel_size = None def prepare(self, request): + random.seed(request.random_seed) + self.ndim = request.get_total_roi().dims() assert self.shift_axis in range(self.ndim) @@ -118,6 +121,9 @@ def prepare(self, request): spec.roi.set_shape(updated_roi.get_shape()) request[key] = spec + deps = request + return deps + def process(self, batch, request): for array_key, array in batch.arrays.items(): sub_shift_array = self.get_sub_shift_array( @@ -126,6 +132,7 @@ def process(self, batch, request): self.shift_array, self.shift_axis, self.lcm_voxel_size) + input_data = array.data ndims = array.spec.roi.dims() logger.debug("Data has shape: %s" % str(input_data.shape)) @@ -152,12 +159,12 @@ def process(self, batch, request): assert (request[array_key].roi.get_shape() == Coordinate(array.data.shape[-ndims:]) * self.lcm_voxel_size), \ - ("request roi shape {} is not the same as " - "generated array shape {}").format( - request[array_key].roi.get_shape(), array.data.shape) + ("request roi shape {} is not the same as " + "generated array shape {}").format( + request[array_key].roi.get_shape(), array.data.shape) batch[array_key] = array - for points_key, points in batch.points.items(): + for points_key, points in batch.graphs.items(): sub_shift_array = self.get_sub_shift_array( request.get_total_roi(), points.spec.roi, @@ -234,24 +241,24 @@ def shift_points( :return a Points object with the updated point locations and ROI """ - data = points.data + nodes = list(points.nodes) + logger.info("nodes before %s", nodes) spec = points.spec shift_axis_start_pos = spec.roi.get_offset()[shift_axis] - shifted_data = {} - for id_, point in data.items(): - loc = Coordinate(point.location) + for node in nodes: + loc = node.location shift_axis_position = loc[shift_axis] - shift_array_index = ((shift_axis_position - shift_axis_start_pos) - // lcm_voxel_size[shift_axis]) + shift_array_index = int((shift_axis_position - shift_axis_start_pos) + // lcm_voxel_size[shift_axis]) assert(shift_array_index >= 0) shift = Coordinate(sub_shift_array[shift_array_index]) - new_loc = loc + shift - if request_roi.contains(new_loc): - point.location = new_loc - shifted_data[id_] = point + # TODO check + loc += shift + if not request_roi.contains(loc): + points.remove_node(node) - points.data = shifted_data + logger.info("nodes after %s", nodes) points.spec.roi = request_roi return points diff --git a/linajea/gunpowder/tracks_source.py b/linajea/gunpowder/tracks_source.py index f16a619..e9e3652 100644 --- a/linajea/gunpowder/tracks_source.py +++ b/linajea/gunpowder/tracks_source.py @@ -118,7 +118,6 @@ def provide(self, request): points_data = self._get_points(point_filter) logger.debug("Points data: %s", points_data) - logger.debug("Type of point: %s", type(points_data[0])) points_spec = GraphSpec(roi=request[self.points].roi.copy()) batch = Batch() From a3c8bf9e9a5a452b59472f0a63c4a6e242ce5956 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 13:22:45 -0400 Subject: [PATCH 070/263] analyze_results: add new functions (all using new config) get_results_sorted: get sorted list of results get_best_result_with_config: get best result using new config get_result_id: get result for specific parameters_id --- linajea/evaluation/__init__.py | 1 + linajea/evaluation/analyze_results.py | 89 ++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/linajea/evaluation/__init__.py b/linajea/evaluation/__init__.py index fd30b56..5bd8b64 100644 --- a/linajea/evaluation/__init__.py +++ b/linajea/evaluation/__init__.py @@ -7,6 +7,7 @@ from .validation_metric import validation_score from .analyze_results import ( get_result, get_results, get_best_result, + get_results_sorted, get_best_result_with_config, get_result_id, get_best_result_per_setup, get_tgmm_results, get_best_tgmm_result) diff --git a/linajea/evaluation/analyze_results.py b/linajea/evaluation/analyze_results.py index 0ceacde..7d7cf39 100644 --- a/linajea/evaluation/analyze_results.py +++ b/linajea/evaluation/analyze_results.py @@ -1,8 +1,12 @@ +import logging +import os +import re + import pandas -from linajea import CandidateDatabase + +from linajea import (CandidateDatabase, + checkOrCreateDB) from linajea.tracking import TrackingParameters -import re -import logging logger = logging.getLogger(__name__) @@ -166,3 +170,82 @@ def get_best_result_per_setup(setups, region, db_host, best_df = pandas.DataFrame(best_results) best_df.sort_values('sum_errors', inplace=True) return best_df + + +def get_results_sorted(config, + filter_params=None, + score_columns=None, + score_weights=None, + sort_by="sum_errors"): + if not score_columns: + score_columns = ['fn_edges', 'identity_switches', + 'fp_divisions', 'fn_divisions'] + if not score_weights: + score_weights = [1.]*len(score_columns) + + db_name = config.inference.data_source.db_name + + logger.info("checking db: %s", db_name) + + candidate_db = CandidateDatabase(db_name, config.general.db_host, 'r') + scores = candidate_db.get_scores(filters=filter_params, + eval_params=config.evaluate.parameters) + + if len(scores) == 0: + raise RuntimeError("no scores found!") + + results_df = pandas.DataFrame(scores) + logger.debug("data types of results_df dataframe columns: %s" + % str(results_df.dtypes)) + if 'param_id' in results_df: + results_df['_id'] = results_df['param_id'] + results_df.set_index('param_id', inplace=True) + + results_df['sum_errors'] = sum([results_df[col]*weight for col, weight + in zip(score_columns, score_weights)]) + results_df['sum_divs'] = sum([results_df[col]*weight for col, weight + in zip(score_columns[-2:], score_weights[-2:])]) + ascending = True + if sort_by == "matched_edges": + ascending = False + results_df.sort_values(sort_by, ascending=ascending, inplace=True) + return results_df + + +def get_best_result_with_config(config, + filter_params=None, + score_columns=None, + score_weights=None): + ''' Gets the best result for the given setup and region according to + the sum of errors in score_columns, with optional weighting. + + Returns a dictionary''' + + results_df = get_results_sorted(config, + filter_params=filter_params, + score_columns=score_columns, + score_weights=score_weights) + best_result = results_df.iloc[0].to_dict() + for key, value in best_result.items(): + try: + best_result[key] = value.item() + except AttributeError: + pass + return best_result + + +def get_result_id( + config, + parameters_id): + ''' Get the scores, statistics, and parameters for given + setup, region, and parameters. + Returns a dictionary containing the keys and values of the score + object. + + tracking_parameters can be a dict or a TrackingParameters object''' + db_name = config.inference.data_source.db_name + candidate_db = CandidateDatabase(db_name, config.general.db_host, 'r') + + result = candidate_db.get_score(parameters_id, + eval_params=config.evaluate.parameters) + return result From 7ecac6d6848ad0408a4ba385856df8bcebf4ee73 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 7 Apr 2021 14:55:31 -0400 Subject: [PATCH 071/263] pass graph copy to validation_score fun validation_score modifies the graph, so pass a copy --- linajea/evaluation/evaluator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/linajea/evaluation/evaluator.py b/linajea/evaluation/evaluator.py index 428c71c..5c17a14 100644 --- a/linajea/evaluation/evaluator.py +++ b/linajea/evaluation/evaluator.py @@ -1,3 +1,4 @@ +from copy import deepcopy import logging import math import networkx as nx @@ -400,6 +401,6 @@ def __get_track_matches(self): def get_validation_score(self): vald_score = validation_score( - self.gt_track_graph, - self.rec_track_graph) + deepcopy(self.gt_track_graph), + deepcopy(self.rec_track_graph)) self.report.set_validation_score(vald_score) From 8f58036407a872306e123197b0c468d333a96a65 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 13 Apr 2021 08:43:08 -0400 Subject: [PATCH 072/263] config: solve: update config for param search --- linajea/config/__init__.py | 3 +- linajea/config/solve.py | 108 +++++++++++++++++------------ linajea/get_next_inference_data.py | 12 +++- 3 files changed, 73 insertions(+), 50 deletions(-) diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index 0091766..673833a 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -15,7 +15,8 @@ from .predict import (PredictCellCycleConfig, PredictTrackingConfig) from .solve import (SolveConfig, - SolveParametersConfig) + SolveParametersMinimalConfig, + SolveParametersNonMinimalConfig) from .tracking_config import TrackingConfig # from .test import (TestTrackingConfig, # TestCellCycleConfig) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 8d561d4..40e8f38 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -12,8 +12,45 @@ logger = logging.getLogger(__name__) +def convert_solve_params_list(): + def converter(vals): + if vals is None: + return None + + assert isinstance(vals, list), "list({})".format(vals) + converted = [] + for val in vals: + if isinstance(val, SolveParametersMinimalConfig): + converted.append(val) + elif isinstance(val, SolveParametersNonMinimalConfig): + converted.append(val) + else: + if "track_cost" in val: + converted.append(SolveParametersMinimalConfig(**val)) + else: + converted.append(SolveParametersNonMinimalConfig(**val)) + return converted + return converter + + +def convert_solve_search_params(): + def converter(vals): + if vals is None: + return None + + if isinstance(vals, SolveParametersMinimalSearchConfig) or \ + isinstance(vals, SolveParametersNonMinimalSearchConfig): + return vals + else: + if "track_cost" in vals: + return SolveParametersMinimalSearchConfig(**vals) + else: + return SolveParametersNonMinimalSearchConfig(**vals) + return converter + + @attr.s(kw_only=True) -class SolveParametersConfig: +class SolveParametersMinimalConfig: track_cost = attr.ib(type=float) weight_node_score = attr.ib(type=float) selection_constant = attr.ib(type=float) @@ -44,7 +81,7 @@ def query(self): @attr.s(kw_only=True) -class SolveParametersSearchConfig: +class SolveParametersMinimalSearchConfig: track_cost = attr.ib(type=List[float]) weight_node_score = attr.ib(type=List[float]) selection_constant = attr.ib(type=List[float]) @@ -72,6 +109,7 @@ class SolveParametersNonMinimalConfig: threshold_edge_score = attr.ib(type=float) weight_prediction_distance_cost = attr.ib(type=float) use_cell_state = attr.ib(type=str, default=None) + use_cell_cycle_indicator = attr.ib(type=str, default=False) threshold_split_score = attr.ib(type=float, default=None) threshold_is_normal_score = attr.ib(type=float, default=None) threshold_is_daughter_score = attr.ib(type=float, default=None) @@ -122,40 +160,44 @@ class SolveParametersNonMinimalSearchConfig: num_random_configs = attr.ib(type=int, default=None) def write_solve_parameters_configs(parameters_search, non_minimal): - params = attr.asdict(parameters_search) + params = {k:v + for k,v in attr.asdict(parameters_search).items() + if v is not None} del params['random_search'] del params['num_random_configs'] - search_keys = list(params.keys()) - if parameters_search.random_search: search_configs = [] assert parameters_search.num_random_configs is not None, \ "set number_configs kwarg when using random search!" for _ in range(parameters_search.num_random_configs): - conf = [] - for _, v in params.items(): + conf = {} + for k, v in params.items(): if not isinstance(v, list): - conf.append(v) + conf[k] = v elif len(v) == 1: - conf.append(v[0]) + conf[k] = v[0] elif isinstance(v[0], str): - conf.append(random.choice(v)) + rnd = random.choice(v) + if rnd == "": + rnd = None + conf[k] = rnd else: assert len(v) == 2, \ "possible options per parameter for random search: " \ "single fixed value, upper and lower bound, " \ - "set of string values" + "set of string values ({})".format(v) if isinstance(v[0], list): idx = random.randrange(len(v[0])) - conf.append(random.uniform(v[0][idx], v[1][idx])) + conf[k] = random.uniform(v[0][idx], v[1][idx]) else: - conf.append(random.uniform(v[0], v[1])) + conf[k] = random.uniform(v[0], v[1]) search_configs.append(conf) else: - search_configs = itertools.product(*[params[key] - for key in search_keys]) + search_configs = [ + dict(zip(params.keys(), x)) + for x in itertools.product(*params.values())] configs = [] for config_vals in search_configs: @@ -164,7 +206,7 @@ def write_solve_parameters_configs(parameters_search, non_minimal): configs.append(SolveParametersNonMinimalConfig( **config_vals)) # type: ignore else: - configs.append(SolveParametersConfig( + configs.append(SolveParametersMinimalConfig( **config_vals)) # type: ignore return configs @@ -174,14 +216,8 @@ def write_solve_parameters_configs(parameters_search, non_minimal): class SolveConfig: job = attr.ib(converter=ensure_cls(JobConfig)) from_scratch = attr.ib(type=bool, default=False) - parameters = attr.ib(converter=ensure_cls_list( - SolveParametersConfig), default=None) - parameters_search = attr.ib(converter=ensure_cls( - SolveParametersSearchConfig), default=None) - parameters_non_minimal = attr.ib(converter=ensure_cls_list( - SolveParametersNonMinimalConfig), default=None) - parameters_non_minimal_search = attr.ib(converter=ensure_cls( - SolveParametersNonMinimalSearchConfig), default=None) + parameters = attr.ib(converter=convert_solve_params_list(), default=None) + parameters_search = attr.ib(converter=convert_solve_search_params(), default=None) non_minimal = attr.ib(type=bool, default=False) write_struct_svm = attr.ib(type=bool, default=False) check_node_close_to_roi = attr.ib(type=bool, default=True) @@ -189,36 +225,16 @@ class SolveConfig: def __attrs_post_init__(self): assert self.parameters is not None or \ - self.parameters_search is not None or \ - self.parameters_non_minimal is not None or \ - self.parameters_non_minimal_search is not None, \ + self.parameters_search is not None, \ "provide either solve parameters or grid/random search values " \ "for solve parameters!" - if self.parameters is not None or self.parameters_search is not None: - assert not self.non_minimal, \ - "please set non_minimal to false when using minimal ilp" - elif self.parameters_non_minimal is not None or \ - self.parameters_non_minimal_search is not None: - assert self.non_minimal, \ - "please set non_minimal to true when using non minimal ilp" - if self.parameters_search is not None: if self.parameters is not None: logger.warning("overwriting explicit solve parameters with " "grid/random search parameters!") self.parameters = write_solve_parameters_configs( - self.parameters_search, non_minimal=False) - elif self.parameters_non_minimal_search is not None: - if self.parameters_non_minimal is not None: - logger.warning("overwriting explicit solve parameters with " - "grid/random search parameters!") - self.parameters = write_solve_parameters_configs( - self.parameters_non_minimal_search, non_minimal=True) - elif self.parameters_non_minimal is not None: - assert self.parameters is None, \ - "overwriting minimal ilp parameters with non-minimal ilp ones" - self.parameters = self.parameters_non_minimal + self.parameters_search, non_minimal=self.non_minimal) # block size and context must be the same for all parameters! block_size = self.parameters[0].block_size diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py index 8b43871..4a9875e 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/get_next_inference_data.py @@ -9,7 +9,8 @@ from linajea import (CandidateDatabase, checkOrCreateDB) from linajea.config import (InferenceDataTrackingConfig, - SolveParametersConfig, + SolveParametersMinimalConfig, + SolveParametersNonMinimalConfig, TrackingConfig) logger = logging.getLogger(__name__) @@ -126,8 +127,13 @@ def fix_solve_parameters_with_pid(config, sample_name, checkpoint, inference, inference.cell_score_threshold) db = CandidateDatabase(db_name, config.general.db_host) parameters = db.get_parameters(pid) - logger.info("getting params %s (id: %s) from validation database %s (sample: %s)", + logger.info("getting params %s (id: %s) from database %s (sample: %s)", parameters, pid, db_name, sample_name) - solve_parameters = [SolveParametersConfig(**parameters)] # type: ignore + try: + solve_parameters = [SolveParametersMinimalConfig(**parameters)] # type: ignore + config.solve.non_minimal = False + except TypeError: + solve_parameters = [SolveParametersNonMinimalConfig(**parameters)] # type: ignore + config.solve.non_minimal = True config.solve.parameters = solve_parameters return config From 09a3bbf89fbae96f382eec243307f7a7961f60e0 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 13 Apr 2021 08:43:42 -0400 Subject: [PATCH 073/263] config: train: update config for use_radius --- linajea/config/train.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linajea/config/train.py b/linajea/config/train.py index 848d07f..0dfa8ec 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -11,7 +11,7 @@ def use_radius_converter(): def converter(val): if isinstance(val, bool): - return {0: val} + return {'0': val} else: return val return converter @@ -39,7 +39,7 @@ class TrainTrackingConfig(TrainConfig): rasterize_radius = attr.ib(type=List[float]) augment = attr.ib(converter=ensure_cls(AugmentTrackingConfig)) parent_vectors_loss_transition = attr.ib(type=int, default=50000) - use_radius = attr.ib(type=Dict[int, int], + use_radius = attr.ib(type=Dict[int, int], default=None, converter=use_radius_converter()) cell_density = attr.ib(default=None) From 054862083a8305d0ed1aa4d938c4d561b2652b13 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 13 Apr 2021 08:45:38 -0400 Subject: [PATCH 074/263] eval: make copy of dict of report --- linajea/evaluation/report.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/linajea/evaluation/report.py b/linajea/evaluation/report.py index 4217718..3abb403 100644 --- a/linajea/evaluation/report.py +++ b/linajea/evaluation/report.py @@ -1,3 +1,5 @@ +from copy import deepcopy + class Report: def __init__(self): # STATISTICS @@ -189,7 +191,7 @@ def get_report(self): return self.__dict__ def get_short_report(self): - report = self.__dict__ + report = deepcopy(self.__dict__) # STATISTICS del report['fn_edge_list'] del report['identity_switch_gt_nodes'] From e89812cd342a1d31c129ca57c44c995b343bcdc0 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 13 Apr 2021 08:51:12 -0400 Subject: [PATCH 075/263] config/track: cell cycle key might be None --- linajea/tracking/track.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index ae0fb14..7e6d060 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -48,7 +48,10 @@ def track(graph, config, selected_key, frame_key='t', frames=None, ''' # cell_cycle_keys = [p.cell_cycle_key for p in config.solve.parameters] - cell_cycle_keys = [p.cell_cycle_key + "mother" for p in config.solve.parameters] + cell_cycle_keys = [p.cell_cycle_key + "mother" + if p.cell_cycle_key is not None + else None + for p in config.solve.parameters] if any(cell_cycle_keys): # remove nodes that don't have a cell cycle key, with warning to_remove = [] From 893c65af7f5cec7e0c81516cfabe75ad7ded9aa8 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 13 Apr 2021 11:27:30 -0400 Subject: [PATCH 076/263] Drop parameters if opening db in write mode --- linajea/candidate_database.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index 79c6fcd..d405eee 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -62,6 +62,14 @@ def __init__( position_attribute=['t', 'z', 'y', 'x'], endpoint_names=endpoint_names ) + if mode == 'w': + try: + self._MongoDbGraphProvider__connect() + self._MongoDbGraphProvider__open_db() + params_collection = self.database['parameters'] + params_collection.drop() + finally: + self._MongoDbGraphProvider__disconnect() self.parameters_id = None self.selected_key = None if parameters_id is not None: From ec62e9916e726a9b0ff8931c4d3f4a3a281d9c12 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 13 Apr 2021 12:16:53 -0400 Subject: [PATCH 077/263] SolveConfig automatically converts single parameter to list --- linajea/config/solve.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 40e8f38..590a9ef 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -12,12 +12,14 @@ logger = logging.getLogger(__name__) + def convert_solve_params_list(): def converter(vals): if vals is None: return None - - assert isinstance(vals, list), "list({})".format(vals) + print(type(vals)) + if not isinstance(vals, list): + vals = [vals] converted = [] for val in vals: if isinstance(val, SolveParametersMinimalConfig): From 6351b0f26544d91cad27d5871a7e5f7c7cc15a62 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 13 Apr 2021 12:17:18 -0400 Subject: [PATCH 078/263] Add default zeros for division weights and constants --- linajea/config/solve.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 590a9ef..7cd8975 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -56,10 +56,10 @@ class SolveParametersMinimalConfig: track_cost = attr.ib(type=float) weight_node_score = attr.ib(type=float) selection_constant = attr.ib(type=float) - weight_division = attr.ib(type=float) - division_constant = attr.ib(type=float) - weight_child = attr.ib(type=float) - weight_continuation = attr.ib(type=float) + weight_division = attr.ib(type=float, default=0.0) + division_constant = attr.ib(type=float, default=0.0) + weight_child = attr.ib(type=float, default=0.0) + weight_continuation = attr.ib(type=float, default=0.0) weight_edge_score = attr.ib(type=float) cell_cycle_key = attr.ib(type=str, default=None) block_size = attr.ib(type=List[int]) From f50f5be63cc045431a5c6ffa67c10eb73afcb816 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 13 Apr 2021 12:18:09 -0400 Subject: [PATCH 079/263] Make solver run without writing struct files flag --- linajea/tracking/solver.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index b9b40dc..08be2bf 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -100,6 +100,13 @@ def _create_indicators(self): node_split_file = open(f"node_split_b{self.block_id}", 'w') node_child_file = open(f"node_child_b{self.block_id}", 'w') node_continuation_file = open(f"node_continuation_b{self.block_id}", 'w') + else: + node_selected_file = None + node_appear_file = None + node_disappear_file = None + node_split_file = None + node_child_file = None + node_continuation_file = None for node in self.graph.nodes: if self.write_struct_svm: node_selected_file.write("{} {}\n".format(node, self.num_vars)) @@ -157,6 +164,14 @@ def _set_objective(self): f"features_node_child_weight_or_constant_b{self.block_id}", 'w') node_continuation_weight_or_constant_file = open( f"features_node_continuation_weight_or_constant_b{self.block_id}", 'w') + else: + node_selected_weight_file = None + node_selected_constant_file = None + node_split_weight_file = None + node_split_constant_file = None + node_child_weight_or_constant_file = None + node_continuation_weight_or_constant_file = None + for node in self.graph.nodes: objective.set_coefficient( self.node_selected[node], @@ -191,6 +206,8 @@ def _set_objective(self): if self.write_struct_svm: edge_selected_weight_file = open( f"features_edge_selected_weight_b{self.block_id}", 'w') + else: + edge_selected_weight_file = None for edge in self.graph.edges(): objective.set_coefficient( self.edge_selected[edge], @@ -281,7 +298,8 @@ def _node_costs(self, node, file_weight, file_constant): def _split_costs(self, node, file_weight, file_constant): # split score times a weight plus a threshold if self.parameters.cell_cycle_key is None: - file_constant.write("{} 1\n".format(self.node_split[node])) + if self.write_struct_svm: + file_constant.write("{} 1\n".format(self.node_split[node])) return 1 split_costs = ( ( @@ -303,8 +321,9 @@ def _split_costs(self, node, file_weight, file_constant): def _child_costs(self, node, file_weight_or_constant): # split score times a weight if self.parameters.cell_cycle_key is None: - file_weight_or_constant.write("{} 1\n".format( - self.node_child[node])) + if self.write_struct_svm: + file_weight_or_constant.write("{} 1\n".format( + self.node_child[node])) return 0 split_costs = ( # self.graph.nodes[node][self.parameters.cell_cycle_key][1] * @@ -323,8 +342,9 @@ def _child_costs(self, node, file_weight_or_constant): def _continuation_costs(self, node, file_weight_or_constant): # split score times a weight if self.parameters.cell_cycle_key is None: - file_weight_or_constant.write("{} 1\n".format( - self.node_child[node])) + if self.write_struct_svm: + file_weight_or_constant.write("{} 1\n".format( + self.node_child[node])) return 0 continuation_costs = ( # self.graph.nodes[node][self.parameters.cell_cycle_key][2] * From f2194c4230e38d2ea0110d7464e5fac4fe03fd4d Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 13 Apr 2021 12:19:34 -0400 Subject: [PATCH 080/263] Pass SolveConfig to track instead of TrackingConfig --- linajea/tracking/track.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 7e6d060..15c4faa 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -19,10 +19,10 @@ def track(graph, config, selected_key, frame_key='t', frames=None, The candidate graph to extract tracks from - config (``TrackingConfig``) + config (``SolveConfig``) Configuration object to be used. The parameters to use when - optimizing the tracking ILP are at config.solve.parameters + optimizing the tracking ILP are at config.parameters (can also be a list of parameters). selected_key (``string``) @@ -47,11 +47,11 @@ def track(graph, config, selected_key, frame_key='t', frames=None, The ID of the current daisy block. ''' - # cell_cycle_keys = [p.cell_cycle_key for p in config.solve.parameters] + # cell_cycle_keys = [p.cell_cycle_key for p in config.parameters] cell_cycle_keys = [p.cell_cycle_key + "mother" if p.cell_cycle_key is not None else None - for p in config.solve.parameters] + for p in config.parameters] if any(cell_cycle_keys): # remove nodes that don't have a cell cycle key, with warning to_remove = [] @@ -93,10 +93,10 @@ def track(graph, config, selected_key, frame_key='t', frames=None, if not solver: solver = Solver( track_graph, parameter, key, frames=frames, - write_struct_svm=config.solve.write_struct_svm, + write_struct_svm=config.write_struct_svm, block_id=block_id, - check_node_close_to_roi=config.solve.check_node_close_to_roi, - add_node_density_constraints=config.solve.add_node_density_constraints) + check_node_close_to_roi=config.check_node_close_to_roi, + add_node_density_constraints=config.add_node_density_constraints) else: solver.update_objective(parameter, key) From fdb307ac391b6d2c394f942f2c89df61d1aa73cc Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 13 Apr 2021 12:19:50 -0400 Subject: [PATCH 081/263] Assume parameters is always a list --- linajea/tracking/track.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 15c4faa..11cd396 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -72,9 +72,8 @@ def track(graph, config, selected_key, frame_key='t', frames=None, logger.info("No nodes in graph - skipping solving step") return - parameters = config.solve.parameters - if not isinstance(parameters, list): - parameters = [parameters] + parameters = config.parameters + if not isinstance(selected_key, list): selected_key = [selected_key] assert len(parameters) == len(selected_key),\ From f5cbd58763479e5bb38bce8a004ac0d0cdffbf6a Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 13 Apr 2021 12:20:16 -0400 Subject: [PATCH 082/263] Update tests for minimal solver --- tests/test_minimal_solver.py | 81 ++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 26 deletions(-) diff --git a/tests/test_minimal_solver.py b/tests/test_minimal_solver.py index 0ec48e4..89d84d2 100644 --- a/tests/test_minimal_solver.py +++ b/tests/test_minimal_solver.py @@ -1,4 +1,5 @@ import linajea.tracking +import linajea.config import logging import linajea import unittest @@ -64,14 +65,15 @@ def test_solver_basic(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = linajea.tracking.TrackingParameters(**ps) + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=ps, job=job) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.track( graph, - parameters, + solve_config, frame_key='t', selected_key='selected') @@ -131,7 +133,7 @@ def test_solver_node_close_to_edge(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = linajea.tracking.TrackingParameters(**ps) + parameters = linajea.config.SolveParametersMinimalConfig(**ps) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) @@ -204,16 +206,17 @@ def test_solver_multiple_configs(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = [linajea.tracking.TrackingParameters(**ps1), - linajea.tracking.TrackingParameters(**ps2)] + parameters = [ps1, ps2] keys = ['selected_1', 'selected_2'] + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=parameters, job=job) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.track( graph, - parameters, + solve_config, frame_key='t', selected_key=keys) @@ -253,17 +256,29 @@ def test_solver_cell_cycle(self): cells = [ {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, - 'vgg_score': [0, 0, 1]}, + 'vgg_scoremother': 0, + "vgg_scoredaughter": 0, + "vgg_scorenormal": 1}, {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, - 'vgg_score': [1, 0, 0]}, + 'vgg_scoremother': 1, + "vgg_scoredaughter": 0, + "vgg_scorenormal": 0}, {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0, - 'vgg_score': [0, 1, 0]}, + 'vgg_scoremother': 0, + "vgg_scoredaughter": 1, + "vgg_scorenormal": 0}, {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, - 'vgg_score': [0, 1, 0]}, + 'vgg_scoremother': 0, + "vgg_scoredaughter": 1, + "vgg_scorenormal": 0}, {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0, - 'vgg_score': [0, 0, 1]}, + 'vgg_scoremother': 0, + "vgg_scoredaughter": 0, + "vgg_scorenormal": 1}, {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, - 'vgg_score': [0, 0, 1]} + 'vgg_scoremother': 0, + "vgg_scoredaughter": 0, + "vgg_scorenormal": 1} ] edges = [ @@ -297,18 +312,19 @@ def test_solver_cell_cycle(self): "max_cell_move": 0.0, "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], + "cell_cycle_key": "vgg_score", } - parameters = linajea.tracking.TrackingParameters(**ps) + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=ps, job=job) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.track( graph, - parameters, + solve_config, frame_key='t', - selected_key='selected', - cell_cycle_key="vgg_score") + selected_key='selected') selected_edges = [] for u, v, data in graph.edges(data=True): @@ -337,17 +353,29 @@ def test_solver_cell_cycle2(self): cells = [ {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, - 'vgg_score': [0, 0, 1]}, + 'vgg_scoremother': 0, + "vgg_scoredaughter": 0, + "vgg_scorenormal": 1}, {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, - 'vgg_score': [0, 0, 1]}, + 'vgg_scoremother': 0, + "vgg_scoredaughter": 0, + "vgg_scorenormal": 1}, {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0, - 'vgg_score': [0, 0, 1]}, + 'vgg_scoremother': 0, + "vgg_scoredaughter": 0, + "vgg_scorenormal": 1}, {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, - 'vgg_score': [0, 0, 1]}, + 'vgg_scoremother': 0, + "vgg_scoredaughter": 0, + "vgg_scorenormal": 1}, {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0, - 'vgg_score': [0, 0, 1]}, + 'vgg_scoremother': 0, + "vgg_scoredaughter": 0, + "vgg_scorenormal": 1}, {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, - 'vgg_score': [0, 0, 1]} + 'vgg_scoremother': 0, + "vgg_scoredaughter": 0, + "vgg_scorenormal": 1}, ] edges = [ @@ -381,18 +409,19 @@ def test_solver_cell_cycle2(self): "max_cell_move": 0.0, "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], + "cell_cycle_key": "vgg_score" } - parameters = linajea.tracking.TrackingParameters(**ps) + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=ps, job=job) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.track( graph, - parameters, + solve_config, frame_key='t', - selected_key='selected', - cell_cycle_key="vgg_score") + selected_key='selected') selected_edges = [] for u, v, data in graph.edges(data=True): From b4d44fb129f15b22c4717fca5c4979943fb5270e Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 13 Apr 2021 12:31:47 -0400 Subject: [PATCH 083/263] Pass SolveConfig to nm_track and assume parameters is list --- linajea/tracking/non_minimal_track.py | 13 ++++++------- tests/test_non_minimal_solver.py | 14 ++++++++------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/linajea/tracking/non_minimal_track.py b/linajea/tracking/non_minimal_track.py index f688fbc..bbe5466 100644 --- a/linajea/tracking/non_minimal_track.py +++ b/linajea/tracking/non_minimal_track.py @@ -18,10 +18,10 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): The candidate graph to extract tracks from - config (``TrackingConfig``) + config (``SolveConfig``) Configuration object to be used. The parameters to use when - optimizing the tracking ILP are at config.solve.parameters + optimizing the tracking ILP are at config.parameters (can also be a list of parameters). selected_key (``string``) @@ -46,9 +46,8 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): if graph.number_of_nodes() == 0: return - parameters = config.solve.parameters - if not isinstance(parameters, list): - parameters = [parameters] + parameters = config.parameters + if not isinstance(selected_key, list): selected_key = [selected_key] assert len(parameters) == len(selected_key),\ @@ -67,8 +66,8 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): if not solver: solver = NMSolver( track_graph, parameter, key, frames=frames, - check_node_close_to_roi=config.solve.check_node_close_to_roi, - add_node_density_constraints=config.solve.add_node_density_constraints) + check_node_close_to_roi=config.check_node_close_to_roi, + add_node_density_constraints=config.add_node_density_constraints) else: solver.update_objective(parameter, key) diff --git a/tests/test_non_minimal_solver.py b/tests/test_non_minimal_solver.py index aa78180..cd3ab55 100644 --- a/tests/test_non_minimal_solver.py +++ b/tests/test_non_minimal_solver.py @@ -59,14 +59,15 @@ def test_solver_basic(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = linajea.tracking.NMTrackingParameters(**ps) + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=ps, job=job) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.nm_track( graph, - parameters, + solve_config, frame_key='t', selected_key='selected') @@ -125,7 +126,7 @@ def test_solver_node_close_to_edge(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = linajea.tracking.NMTrackingParameters(**ps) + parameters = linajea.config.SolveParametersNonMinimalConfig(**ps) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) @@ -198,16 +199,17 @@ def test_solver_multiple_configs(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = [linajea.tracking.NMTrackingParameters(**ps1), - linajea.tracking.NMTrackingParameters(**ps2)] + parameters = [ps1, ps2] keys = ['selected_1', 'selected_2'] + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=parameters, job=job) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.nm_track( graph, - parameters, + solve_config, frame_key='t', selected_key=keys) From 47496f86a8162a460ca285b9abed2fc3dd07c1bd Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 13 Apr 2021 12:47:37 -0400 Subject: [PATCH 084/263] Use batch.graphs instead of batch.points in tracks_source, update tests --- linajea/gunpowder/tracks_source.py | 2 +- tests/test_tracks_source.py | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/linajea/gunpowder/tracks_source.py b/linajea/gunpowder/tracks_source.py index e9e3652..9e0f6c0 100644 --- a/linajea/gunpowder/tracks_source.py +++ b/linajea/gunpowder/tracks_source.py @@ -121,7 +121,7 @@ def provide(self, request): points_spec = GraphSpec(roi=request[self.points].roi.copy()) batch = Batch() - batch.points[self.points] = Graph(points_data, [], points_spec) + batch.graphs[self.points] = Graph(points_data, [], points_spec) timing.stop() batch.profiling_stats.add(timing) diff --git a/tests/test_tracks_source.py b/tests/test_tracks_source.py index 4877555..28e471a 100644 --- a/tests/test_tracks_source.py +++ b/tests/test_tracks_source.py @@ -41,7 +41,7 @@ def tearDown(self): os.remove(TEST_FILE_WITH_HEADER) def test_parent_location(self): - points = gp.PointsKey("POINTS") + points = gp.GraphKey("POINTS") ts = TracksSource( TEST_FILE, points) @@ -53,18 +53,18 @@ def test_parent_location(self): ts.setup() b = ts.provide(request) - points = b[points].data + points = [n.location for n in b[points].nodes] self.assertListEqual([0.0, 0.0, 0.0, 0.0], - list(points[1].location)) + list(points[0])) self.assertListEqual([1.0, 0.0, 0.0, 0.0], - list(points[2].location)) + list(points[1])) self.assertListEqual([1.0, 1.0, 2.0, 3.0], - list(points[3].location)) + list(points[2])) self.assertListEqual([2.0, 2.0, 2.0, 2.0], - list(points[4].location)) + list(points[3])) def test_csv_header(self): - points = gp.PointsKey("POINTS") + points = gp.GraphKey("POINTS") tswh = TracksSource( TEST_FILE_WITH_HEADER, points) @@ -76,18 +76,18 @@ def test_csv_header(self): tswh.setup() b = tswh.provide(request) - points = b[points].data + points = [n.location for n in b[points].nodes] self.assertListEqual([0.0, 0.0, 0.0, 0.0], - list(points[1].location)) + list(points[0])) self.assertListEqual([1.0, 0.0, 0.0, 0.0], - list(points[2].location)) + list(points[1])) self.assertListEqual([1.0, 1.0, 2.0, 3.0], - list(points[3].location)) + list(points[2])) self.assertListEqual([2.0, 2.0, 2.0, 2.0], - list(points[4].location)) + list(points[3])) def test_delete_points_in_context(self): - points = gp.PointsKey("POINTS") + points = gp.GraphKey("POINTS") pv_array = gp.ArrayKey("PARENT_VECTORS") mask = gp.ArrayKey("MASK") radius = [0.1, 0.1, 0.1, 0.1] @@ -118,7 +118,7 @@ def test_delete_points_in_context(self): pipeline.request_batch(request) def test_add_parent_vectors(self): - points = gp.PointsKey("POINTS") + points = gp.GraphKey("POINTS") pv_array = gp.ArrayKey("PARENT_VECTORS") mask = gp.ArrayKey("MASK") radius = [0.1, 0.1, 0.1, 0.1] @@ -148,7 +148,7 @@ def test_add_parent_vectors(self): with gp.build(pipeline): batch = pipeline.request_batch(request) - points = batch[points].data + points = [n.location for n in batch[points].nodes] expected_mask = np.zeros(shape=(1, 4, 4, 4)) expected_mask[0, 0, 0, 0] = 1 expected_mask[0, 1, 2, 3] = 1 From 855745fb189fdd07c2aee830b2aacc117e030caf Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 09:24:53 -0400 Subject: [PATCH 085/263] Update docstring of candidate database to drop parameters db --- linajea/candidate_database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index d405eee..0c61202 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -26,7 +26,7 @@ class CandidateDatabase(MongoDbGraphProvider): mode (``string``, optional): One of ``r``, ``r+``, or ``w``. Defaults to ``r+``. ``w`` drops the - node, edge, and meta collections. + node, edge, meta, and parameters collections. total_roi (``bool``, optional): From 96c5e059c6a21b3044485cc95ca5fa18dcf714b9 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 09:28:48 -0400 Subject: [PATCH 086/263] Remove print statement from solve config --- linajea/config/solve.py | 1 - 1 file changed, 1 deletion(-) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 7cd8975..cdd1ed0 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -17,7 +17,6 @@ def convert_solve_params_list(): def converter(vals): if vals is None: return None - print(type(vals)) if not isinstance(vals, list): vals = [vals] converted = [] From 71bf47c446b5fafa7057cc1c485243ad2f21c0c5 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 09:31:20 -0400 Subject: [PATCH 087/263] Revert "Pass SolveConfig to track instead of TrackingConfig" This reverts commit 6a4eba3cc32275a045c7dd0beaa7ce771166f07f. --- linajea/tracking/track.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 11cd396..794ce67 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -19,10 +19,10 @@ def track(graph, config, selected_key, frame_key='t', frames=None, The candidate graph to extract tracks from - config (``SolveConfig``) + config (``TrackingConfig``) Configuration object to be used. The parameters to use when - optimizing the tracking ILP are at config.parameters + optimizing the tracking ILP are at config.solve.parameters (can also be a list of parameters). selected_key (``string``) @@ -47,11 +47,11 @@ def track(graph, config, selected_key, frame_key='t', frames=None, The ID of the current daisy block. ''' - # cell_cycle_keys = [p.cell_cycle_key for p in config.parameters] + # cell_cycle_keys = [p.cell_cycle_key for p in config.solve.parameters] cell_cycle_keys = [p.cell_cycle_key + "mother" if p.cell_cycle_key is not None else None - for p in config.parameters] + for p in config.solve.parameters] if any(cell_cycle_keys): # remove nodes that don't have a cell cycle key, with warning to_remove = [] @@ -92,10 +92,10 @@ def track(graph, config, selected_key, frame_key='t', frames=None, if not solver: solver = Solver( track_graph, parameter, key, frames=frames, - write_struct_svm=config.write_struct_svm, + write_struct_svm=config.solve.write_struct_svm, block_id=block_id, - check_node_close_to_roi=config.check_node_close_to_roi, - add_node_density_constraints=config.add_node_density_constraints) + check_node_close_to_roi=config.solve.check_node_close_to_roi, + add_node_density_constraints=config.solve.add_node_density_constraints) else: solver.update_objective(parameter, key) From 0ac2ebc0777d1b260c6c5639195f3f48b785fa1b Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 09:31:38 -0400 Subject: [PATCH 088/263] Revert "Pass SolveConfig to nm_track and assume parameters is list" This reverts commit 55ae3cf21479131a1ec85f60f1cc0dfebc4095b5. --- linajea/tracking/non_minimal_track.py | 13 +++++++------ tests/test_non_minimal_solver.py | 14 ++++++-------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/linajea/tracking/non_minimal_track.py b/linajea/tracking/non_minimal_track.py index bbe5466..f688fbc 100644 --- a/linajea/tracking/non_minimal_track.py +++ b/linajea/tracking/non_minimal_track.py @@ -18,10 +18,10 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): The candidate graph to extract tracks from - config (``SolveConfig``) + config (``TrackingConfig``) Configuration object to be used. The parameters to use when - optimizing the tracking ILP are at config.parameters + optimizing the tracking ILP are at config.solve.parameters (can also be a list of parameters). selected_key (``string``) @@ -46,8 +46,9 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): if graph.number_of_nodes() == 0: return - parameters = config.parameters - if not isinstance(selected_key, list): + parameters = config.solve.parameters + if not isinstance(parameters, list): + parameters = [parameters] selected_key = [selected_key] assert len(parameters) == len(selected_key),\ @@ -66,8 +67,8 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): if not solver: solver = NMSolver( track_graph, parameter, key, frames=frames, - check_node_close_to_roi=config.check_node_close_to_roi, - add_node_density_constraints=config.add_node_density_constraints) + check_node_close_to_roi=config.solve.check_node_close_to_roi, + add_node_density_constraints=config.solve.add_node_density_constraints) else: solver.update_objective(parameter, key) diff --git a/tests/test_non_minimal_solver.py b/tests/test_non_minimal_solver.py index cd3ab55..aa78180 100644 --- a/tests/test_non_minimal_solver.py +++ b/tests/test_non_minimal_solver.py @@ -59,15 +59,14 @@ def test_solver_basic(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - job = {"num_workers": 5, "queue": "normal"} - solve_config = linajea.config.SolveConfig(parameters=ps, job=job) + parameters = linajea.tracking.NMTrackingParameters(**ps) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.nm_track( graph, - solve_config, + parameters, frame_key='t', selected_key='selected') @@ -126,7 +125,7 @@ def test_solver_node_close_to_edge(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = linajea.config.SolveParametersNonMinimalConfig(**ps) + parameters = linajea.tracking.NMTrackingParameters(**ps) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) @@ -199,17 +198,16 @@ def test_solver_multiple_configs(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = [ps1, ps2] + parameters = [linajea.tracking.NMTrackingParameters(**ps1), + linajea.tracking.NMTrackingParameters(**ps2)] keys = ['selected_1', 'selected_2'] - job = {"num_workers": 5, "queue": "normal"} - solve_config = linajea.config.SolveConfig(parameters=parameters, job=job) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.nm_track( graph, - solve_config, + parameters, frame_key='t', selected_key=keys) From f2e1b3ff850262992797cc8a569828877b602e21 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 09:34:16 -0400 Subject: [PATCH 089/263] Assume parameters is list in non minimal track --- linajea/tracking/non_minimal_track.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/linajea/tracking/non_minimal_track.py b/linajea/tracking/non_minimal_track.py index f688fbc..9443fa2 100644 --- a/linajea/tracking/non_minimal_track.py +++ b/linajea/tracking/non_minimal_track.py @@ -47,8 +47,7 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): return parameters = config.solve.parameters - if not isinstance(parameters, list): - parameters = [parameters] + if not isinstance(selected_key, list): selected_key = [selected_key] assert len(parameters) == len(selected_key),\ From 89ab0931f135c3d8b01dd8198b10b0e528939e75 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 09:43:27 -0400 Subject: [PATCH 090/263] Fix one remaining reference to solve config in track --- linajea/tracking/track.py | 2 +- tests/test_non_minimal_solver.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 794ce67..1ad2e1b 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -72,7 +72,7 @@ def track(graph, config, selected_key, frame_key='t', frames=None, logger.info("No nodes in graph - skipping solving step") return - parameters = config.parameters + parameters = config.solve.parameters if not isinstance(selected_key, list): selected_key = [selected_key] diff --git a/tests/test_non_minimal_solver.py b/tests/test_non_minimal_solver.py index aa78180..cd3ab55 100644 --- a/tests/test_non_minimal_solver.py +++ b/tests/test_non_minimal_solver.py @@ -59,14 +59,15 @@ def test_solver_basic(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = linajea.tracking.NMTrackingParameters(**ps) + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=ps, job=job) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.nm_track( graph, - parameters, + solve_config, frame_key='t', selected_key='selected') @@ -125,7 +126,7 @@ def test_solver_node_close_to_edge(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = linajea.tracking.NMTrackingParameters(**ps) + parameters = linajea.config.SolveParametersNonMinimalConfig(**ps) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) @@ -198,16 +199,17 @@ def test_solver_multiple_configs(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = [linajea.tracking.NMTrackingParameters(**ps1), - linajea.tracking.NMTrackingParameters(**ps2)] + parameters = [ps1, ps2] keys = ['selected_1', 'selected_2'] + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=parameters, job=job) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.nm_track( graph, - parameters, + solve_config, frame_key='t', selected_key=keys) From 007ee2d427b45fc10688449707b90bca0d51ea36 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 09:43:50 -0400 Subject: [PATCH 091/263] Update solver tests to use fake TrackingConfig --- tests/test_minimal_solver.py | 16 ++++++++++++---- tests/test_non_minimal_solver.py | 13 ++++++++++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/test_minimal_solver.py b/tests/test_minimal_solver.py index 89d84d2..22909da 100644 --- a/tests/test_minimal_solver.py +++ b/tests/test_minimal_solver.py @@ -9,6 +9,10 @@ logging.basicConfig(level=logging.INFO) # logging.getLogger('linajea.tracking').setLevel(logging.DEBUG) +class TestTrackingConfig(): + def __init__(self, solve_config): + self.solve = solve_config + class TestSolver(unittest.TestCase): @@ -67,13 +71,14 @@ def test_solver_basic(self): } job = {"num_workers": 5, "queue": "normal"} solve_config = linajea.config.SolveConfig(parameters=ps, job=job) + config = TestTrackingConfig(solve_config) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.track( graph, - solve_config, + config, frame_key='t', selected_key='selected') @@ -210,13 +215,14 @@ def test_solver_multiple_configs(self): keys = ['selected_1', 'selected_2'] job = {"num_workers": 5, "queue": "normal"} solve_config = linajea.config.SolveConfig(parameters=parameters, job=job) + config = TestTrackingConfig(solve_config) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.track( graph, - solve_config, + config, frame_key='t', selected_key=keys) @@ -316,13 +322,14 @@ def test_solver_cell_cycle(self): } job = {"num_workers": 5, "queue": "normal"} solve_config = linajea.config.SolveConfig(parameters=ps, job=job) + config = TestTrackingConfig(solve_config) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.track( graph, - solve_config, + config, frame_key='t', selected_key='selected') @@ -413,13 +420,14 @@ def test_solver_cell_cycle2(self): } job = {"num_workers": 5, "queue": "normal"} solve_config = linajea.config.SolveConfig(parameters=ps, job=job) + config = TestTrackingConfig(solve_config) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.track( graph, - solve_config, + config, frame_key='t', selected_key='selected') diff --git a/tests/test_non_minimal_solver.py b/tests/test_non_minimal_solver.py index cd3ab55..6070bd7 100644 --- a/tests/test_non_minimal_solver.py +++ b/tests/test_non_minimal_solver.py @@ -9,6 +9,11 @@ # logging.getLogger('linajea.tracking').setLevel(logging.DEBUG) +class TestTrackingConfig(): + def __init__(self, solve_config): + self.solve = solve_config + + class TestSolver(unittest.TestCase): def delete_db(self, db_name, db_host): @@ -61,13 +66,14 @@ def test_solver_basic(self): } job = {"num_workers": 5, "queue": "normal"} solve_config = linajea.config.SolveConfig(parameters=ps, job=job) + config = TestTrackingConfig(solve_config) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.nm_track( graph, - solve_config, + config, frame_key='t', selected_key='selected') @@ -141,7 +147,7 @@ def test_solver_node_close_to_edge(self): close = not close self.assertFalse(close) self.delete_db(db_name, db_host) - + def test_solver_multiple_configs(self): # x @@ -203,13 +209,14 @@ def test_solver_multiple_configs(self): keys = ['selected_1', 'selected_2'] job = {"num_workers": 5, "queue": "normal"} solve_config = linajea.config.SolveConfig(parameters=parameters, job=job) + config = TestTrackingConfig(solve_config) graph.add_nodes_from([(cell['id'], cell) for cell in cells]) graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.nm_track( graph, - solve_config, + config, frame_key='t', selected_key=keys) From e18e9ce140b07885bcccd23613769543c3c5186f Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 14:50:42 -0400 Subject: [PATCH 092/263] Remove frames from name of score collection --- linajea/candidate_database.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index 0c61202..d4b9909 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -224,13 +224,6 @@ def get_score(self, parameters_id, eval_params=None): try: score_collection = self.database['scores'] - # for backwards compatibility - if eval_params.frame_start is not None: - score_collection = self.database[ - 'scores_' + - str(eval_params.frame_start) + "_" + - str(eval_params.frame_end)] - query = {'param_id': parameters_id} query.update(eval_params.valid()) old_score = score_collection.find_one(query) @@ -255,13 +248,6 @@ def get_scores(self, frames=None, filters=None, eval_params=None): query = {} score_collection = self.database['scores'] - # for backwards compatibility - if eval_params.frame_start is not None: - score_collection = self.database[ - 'scores_' + - str(eval_params.frame_start) + "_" + - str(eval_params.frame_end)] - query.update(eval_params.valid()) scores = list(score_collection.find(query)) logger.debug("Found %d scores" % len(scores)) @@ -286,13 +272,6 @@ def write_score(self, parameters_id, report, eval_params=None): self._MongoDbGraphProvider__open_db() try: score_collection = self.database['scores'] - # for backwards compatibility - if eval_params.frame_start is not None: - score_collection = self.database[ - 'scores_' + - str(eval_params.frame_start) + "_" + - str(eval_params.frame_end)] - query = {'param_id': parameters_id} query.update(eval_params.valid()) From 70594cb075c055fd0a97ca4115cecf142883fb35 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 15:04:27 -0400 Subject: [PATCH 093/263] Remove deprecated frames from evaluate config --- linajea/config/evaluate.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 0cb7788..43a7ab6 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -11,17 +11,6 @@ class _EvaluateParametersConfig: matching_threshold = attr.ib(type=int) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) - # deprecated - frames = attr.ib(type=List[int], default=None) - # deprecated - frame_start = attr.ib(type=int, default=None) - # deprecated - frame_end = attr.ib(type=int, default=None) - # deprecated - limit_to_roi_offset = attr.ib(type=List[int], default=None) - # deprecated - limit_to_roi_shape = attr.ib(type=List[int], default=None) - # deprecated sparse = attr.ib(type=bool) def __attrs_post_init__(self): From 607f353f87e38c010b27760b5bd37d6cf70ace0f Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 15:05:38 -0400 Subject: [PATCH 094/263] Check if eval_params is none before updating query --- linajea/candidate_database.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index d4b9909..81ae78a 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -225,7 +225,8 @@ def get_score(self, parameters_id, eval_params=None): try: score_collection = self.database['scores'] query = {'param_id': parameters_id} - query.update(eval_params.valid()) + if eval_params is not None: + query.update(eval_params.valid()) old_score = score_collection.find_one(query) if old_score: del old_score['_id'] @@ -248,7 +249,8 @@ def get_scores(self, frames=None, filters=None, eval_params=None): query = {} score_collection = self.database['scores'] - query.update(eval_params.valid()) + if eval_params is not None: + query.update(eval_params.valid()) scores = list(score_collection.find(query)) logger.debug("Found %d scores" % len(scores)) # for backwards compatibility @@ -273,7 +275,8 @@ def write_score(self, parameters_id, report, eval_params=None): try: score_collection = self.database['scores'] query = {'param_id': parameters_id} - query.update(eval_params.valid()) + if eval_params is not None: + query.update(eval_params.valid()) cnt = score_collection.count_documents(query) assert cnt <= 1, "multiple scores for query %s exist, don't know which to overwrite" % query From bd74ddf31c7eeb2c0b28ccbfe4045edffed7018d Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 15:06:28 -0400 Subject: [PATCH 095/263] Remove all remaining backward compat for frames in cand_db --- linajea/candidate_database.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index 81ae78a..d6fcc99 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -236,7 +236,7 @@ def get_score(self, parameters_id, eval_params=None): self._MongoDbGraphProvider__disconnect() return score - def get_scores(self, frames=None, filters=None, eval_params=None): + def get_scores(self, filters=None, eval_params=None): '''Returns the a list of all score dictionaries or None if no score available''' self._MongoDbGraphProvider__connect() @@ -253,10 +253,6 @@ def get_scores(self, frames=None, filters=None, eval_params=None): query.update(eval_params.valid()) scores = list(score_collection.find(query)) logger.debug("Found %d scores" % len(scores)) - # for backwards compatibility - for score in scores: - if 'param_id' not in score: - score['param_id'] = score['_id'] finally: self._MongoDbGraphProvider__disconnect() From 4491c2678919561ef4ca36e8b4832f14a50fc496 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 15:06:54 -0400 Subject: [PATCH 096/263] Add documentation to write and get score functions --- linajea/candidate_database.py | 37 ++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index d6fcc99..9e754d4 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -217,7 +217,16 @@ def set_parameters_id(self, parameters_id): def get_score(self, parameters_id, eval_params=None): '''Returns the score for the given parameters_id, or - None if no score available''' + None if no score available + Arguments: + + parameters_id (``int``): + The parameters ID to return the score of + + eval_params (``_EvaluateParametersConfig``): + Additional parameters used for evaluation (e.g. roi, + matching threshold, sparsity) + ''' self._MongoDbGraphProvider__connect() self._MongoDbGraphProvider__open_db() score = None @@ -238,7 +247,16 @@ def get_score(self, parameters_id, eval_params=None): def get_scores(self, filters=None, eval_params=None): '''Returns the a list of all score dictionaries or - None if no score available''' + None if no score available + Arguments: + + parameters_id (``int``): + The parameters ID to return the score of + + eval_params (``_EvaluateParametersConfig``): + Additional parameters used for evaluation (e.g. roi, + matching threshold, sparsity) + ''' self._MongoDbGraphProvider__connect() self._MongoDbGraphProvider__open_db() @@ -260,7 +278,20 @@ def get_scores(self, filters=None, eval_params=None): def write_score(self, parameters_id, report, eval_params=None): '''Writes the score for the given parameters_id to the - scores collection, along with the associated parameters''' + scores collection, along with the associated parameters + + Arguments: + + parameters_id (``int``): + The parameters ID to write the score of + + report (``linajea.evaluation.Report``): + The report with the scores to write + + eval_params (``linajea.config._EvaluateParametersConfig``): + Additional parameters used for evaluation (e.g. roi, + matching threshold, sparsity) + ''' parameters = self.get_parameters(parameters_id) if parameters is None: logger.warning("No parameters with id %d. Saving with key only", From 691445f40cc5c2df0adf6056a7f9ac0aec19809f Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 15:07:13 -0400 Subject: [PATCH 097/263] Use get_report function instead of calling __dict__ --- linajea/candidate_database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index 9e754d4..f2cad7b 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -311,7 +311,7 @@ def write_score(self, parameters_id, report, eval_params=None): if parameters is None: parameters = {} logger.info("writing scores for %s to %s", parameters, query) - parameters.update(report.__dict__) + parameters.update(report.get_report()) parameters.update(query) score_collection.replace_one(query, parameters, From f074a607cc8a46b0c4c321eb8ed69d81f87ddaee Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 15:07:51 -0400 Subject: [PATCH 098/263] Update tests for candidate database to use new configs --- tests/test_candidate_database.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_candidate_database.py b/tests/test_candidate_database.py index 8348ebd..01d32c5 100644 --- a/tests/test_candidate_database.py +++ b/tests/test_candidate_database.py @@ -142,7 +142,7 @@ def test_write_and_get_score(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = linajea.tracking.TrackingParameters(**ps) + parameters = linajea.config.SolveParametersMinimalConfig(**ps) db = CandidateDatabase( db_name, @@ -159,7 +159,8 @@ def test_write_and_get_score(self): compare_dict = score.__dict__ compare_dict.update(db.get_parameters(params_id)) - self.assertDictEqual(compare_dict, score_dict) + compare_dict.update({'param_id': params_id}) + self.assertEqual(compare_dict, score_dict) class TestParameterIds(TestCase): @@ -187,9 +188,9 @@ def test_unique_id_one_worker(self): db_host, mode='w') for i in range(10): - tp = linajea.tracking.TrackingParameters( + tp = linajea.config.SolveParametersMinimalConfig( **self.get_tracking_params()) - tp.cost_appear = i + tp.track_cost = i _id = db.get_parameters_id(tp) self.assertEqual(_id, i + 1) self.delete_db(db_name, db_host) @@ -203,7 +204,7 @@ def test_unique_id_multi_worker(self): mode='w') tps = [] for i in range(10): - tp = linajea.tracking.TrackingParameters( + tp = linajea.config.SolveParametersMinimalConfig( **self.get_tracking_params()) tp.cost_appear = i tps.append(tp) From 7ec49cba9db79cf39823e7a77f57ac0161c1f7a7 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 14 Apr 2021 16:06:03 -0400 Subject: [PATCH 099/263] Update version to 1.4-dev --- setup.py | 2 +- singularity/Makefile | 2 +- singularity/Singularity | 2 +- singularity/pylp_base/Makefile | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 5395b5d..f73c78f 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='linajea', - version='1.3', + version='1.4-dev', description='Lineage Tracking in 4D Microscopy Volumes.', url='https://github.com/funkelab/linajea', author='Jan Funke', diff --git a/singularity/Makefile b/singularity/Makefile index bdb57fc..7369447 100644 --- a/singularity/Makefile +++ b/singularity/Makefile @@ -1,4 +1,4 @@ -TAG="linajea:v1.3" +TAG="linajea:v1.4-dev" TMP_FILE:=$(shell mktemp).img diff --git a/singularity/Singularity b/singularity/Singularity index bc79468..d3c6399 100644 --- a/singularity/Singularity +++ b/singularity/Singularity @@ -1,5 +1,5 @@ Bootstrap: localimage -From: /nrs/funke/singularity/linajea/pylp_base:v1.3.img +From: /nrs/funke/singularity/linajea/pylp_base:v1.4-dev.img %help This container contains linajea lineage tracking software. diff --git a/singularity/pylp_base/Makefile b/singularity/pylp_base/Makefile index d5ed746..517a9d7 100644 --- a/singularity/pylp_base/Makefile +++ b/singularity/pylp_base/Makefile @@ -1,4 +1,4 @@ -TAG="pylp_base:v1.3" +TAG="pylp_base:v1.4-dev" TMP_FILE:=$(shell mktemp).img From 9b1b8599121979b851629ff1b8dac02f8abec49c Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 15 Apr 2021 09:22:08 -0400 Subject: [PATCH 100/263] Revert "Remove deprecated frames from evaluate config" This reverts commit baac70655c55f887167fb6f1cd1f09e98ce12ceb. --- linajea/config/evaluate.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 43a7ab6..0cb7788 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -11,6 +11,17 @@ class _EvaluateParametersConfig: matching_threshold = attr.ib(type=int) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + # deprecated + frames = attr.ib(type=List[int], default=None) + # deprecated + frame_start = attr.ib(type=int, default=None) + # deprecated + frame_end = attr.ib(type=int, default=None) + # deprecated + limit_to_roi_offset = attr.ib(type=List[int], default=None) + # deprecated + limit_to_roi_shape = attr.ib(type=List[int], default=None) + # deprecated sparse = attr.ib(type=bool) def __attrs_post_init__(self): From 586bb316c05d60b97e80b8c788c0040856e2c48a Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Apr 2021 10:42:34 -0400 Subject: [PATCH 101/263] Remove underscores from eval config classes --- linajea/candidate_database.py | 6 +++--- linajea/config/evaluate.py | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index f2cad7b..44b6c5e 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -223,7 +223,7 @@ def get_score(self, parameters_id, eval_params=None): parameters_id (``int``): The parameters ID to return the score of - eval_params (``_EvaluateParametersConfig``): + eval_params (``EvaluateParametersConfig``): Additional parameters used for evaluation (e.g. roi, matching threshold, sparsity) ''' @@ -253,7 +253,7 @@ def get_scores(self, filters=None, eval_params=None): parameters_id (``int``): The parameters ID to return the score of - eval_params (``_EvaluateParametersConfig``): + eval_params (``EvaluateParametersConfig``): Additional parameters used for evaluation (e.g. roi, matching threshold, sparsity) ''' @@ -288,7 +288,7 @@ def write_score(self, parameters_id, report, eval_params=None): report (``linajea.evaluation.Report``): The report with the scores to write - eval_params (``linajea.config._EvaluateParametersConfig``): + eval_params (``linajea.config.EvaluateParametersConfig``): Additional parameters used for evaluation (e.g. roi, matching threshold, sparsity) ''' diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 0cb7788..02e08c6 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -8,7 +8,7 @@ @attr.s(kw_only=True) -class _EvaluateParametersConfig: +class EvaluateParametersConfig: matching_threshold = attr.ib(type=int) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) # deprecated @@ -46,28 +46,28 @@ def query(self): @attr.s(kw_only=True) -class _EvaluateConfig: +class EvaluateConfig: job = attr.ib(converter=ensure_cls(JobConfig), default=None) @attr.s(kw_only=True) -class EvaluateTrackingConfig(_EvaluateConfig): +class EvaluateTrackingConfig(EvaluateConfig): from_scratch = attr.ib(type=bool, default=False) - parameters = attr.ib(converter=ensure_cls(_EvaluateParametersConfig)) + parameters = attr.ib(converter=ensure_cls(EvaluateParametersConfig)) @attr.s(kw_only=True) -class _EvaluateParametersCellCycleConfig: +class EvaluateParametersCellCycleConfig: matching_threshold = attr.ib() roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) @attr.s(kw_only=True) -class EvaluateCellCycleConfig(_EvaluateConfig): +class EvaluateCellCycleConfig(EvaluateConfig): max_samples = attr.ib(type=int) metric = attr.ib(type=str) one_off = attr.ib(type=bool) prob_threshold = attr.ib(type=float) dry_run = attr.ib(type=bool) find_fn = attr.ib(type=bool) - parameters = attr.ib(converter=ensure_cls(_EvaluateParametersCellCycleConfig)) + parameters = attr.ib(converter=ensure_cls(EvaluateParametersCellCycleConfig)) From 4122ac166f9396c1add969adcc3ed982379ebfa1 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Apr 2021 10:52:50 -0400 Subject: [PATCH 102/263] Revert "Add flag to use pv distance in extract edges" This reverts commit fbcd47e269d85e9f2c3feaf74eeafbc1c365bc63. Will be reimplemented after config class merge --- .../extract_edges_blockwise.py | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/linajea/process_blockwise/extract_edges_blockwise.py b/linajea/process_blockwise/extract_edges_blockwise.py index 15b0c89..2f5760e 100644 --- a/linajea/process_blockwise/extract_edges_blockwise.py +++ b/linajea/process_blockwise/extract_edges_blockwise.py @@ -12,20 +12,29 @@ logger = logging.getLogger(__name__) -def extract_edges_blockwise(linajea_config): - - data = linajea_config.inference.data_source - voxel_size = daisy.Coordinate(data.voxel_size) - extract_roi = daisy.Roi(offset=data.roi.offset, - shape=data.roi.shape) - # allow for solve context - extract_roi = extract_roi.grow( - daisy.Coordinate(linajea_config.solve.parameters[0].context), - daisy.Coordinate(linajea_config.solve.parameters[0].context)) - # but limit to actual file roi - extract_roi = extract_roi.intersect( - daisy.Roi(offset=data.datafile.file_roi.offset, - shape=data.datafile.file_roi.shape)) +def extract_edges_blockwise( + db_host, + db_name, + sample, + edge_move_threshold, + block_size, + num_workers, + frames=None, + frame_context=1, + data_dir='../01_data', + **kwargs): + + voxel_size, source_roi = get_source_roi(data_dir, sample) + + # limit to specific frames, if given + if frames: + begin, end = frames + begin -= frame_context + end += frame_context + crop_roi = daisy.Roi( + (begin, None, None, None), + (end - begin, None, None, None)) + source_roi = source_roi.intersect(crop_roi) # block size in world units block_write_roi = daisy.Roi( @@ -56,7 +65,9 @@ def extract_edges_blockwise(linajea_config): block_read_roi, block_write_roi, process_function=lambda b: extract_edges_in_block( - linajea_config, + db_name, + db_host, + edge_move_threshold, b), check_function=lambda b: check_function( b, @@ -70,7 +81,9 @@ def extract_edges_blockwise(linajea_config): def extract_edges_in_block( - linajea_config, + db_name, + db_host, + edge_move_threshold, block): logger.info( @@ -148,15 +161,9 @@ def extract_edges_in_block( nex_cell_center = nex_cell[1] nex_parent_center = nex_cell_center + nex_cell[2] - if use_pv_distance: - pre_cells_indices = pre_kd_tree.query_ball_point( - nex_parent_center, - edge_move_threshold) - - else: - pre_cells_indices = pre_kd_tree.query_ball_point( - nex_cell_center, - edge_move_threshold) + pre_cells_indices = pre_kd_tree.query_ball_point( + nex_cell_center, + edge_move_threshold) pre_cells = [all_pre_cells[i] for i in pre_cells_indices] logger.debug( From 6d3e917f6247d6319bc90cee815d4139b53c53e1 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Apr 2021 11:00:33 -0400 Subject: [PATCH 103/263] Revert "Use valid padding for prediction" This reverts commit ce3e67957125e92b5cdbb255f59d163d4b23aa42. Will be reimplemented after merging config classes --- .../process_blockwise/predict_blockwise.py | 137 ++++++++++++------ 1 file changed, 90 insertions(+), 47 deletions(-) diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index fbf1c7d..ad42d9e 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -15,21 +15,49 @@ logger = logging.getLogger(__name__) -def predict_blockwise(linajea_config): - setup_dir = linajea_config.general.setup_dir - - data = linajea_config.inference.data_source - voxel_size = daisy.Coordinate(data.voxel_size) - predict_roi = daisy.Roi(offset=data.roi.offset, - shape=data.roi.shape) - # allow for solve context - predict_roi = predict_roi.grow( - daisy.Coordinate(linajea_config.solve.parameters[0].context), - daisy.Coordinate(linajea_config.solve.parameters[0].context)) - # but limit to actual file roi - predict_roi = predict_roi.intersect( - daisy.Roi(offset=data.datafile.file_roi.offset, - shape=data.datafile.file_roi.shape)) +def predict_blockwise( + config_file, + iteration + ): + config = { + "solve_context": daisy.Coordinate((2, 100, 100, 100)), + "num_workers": 16, + "data_dir": '../01_data', + "setups_dir": '../02_setups', + } + master_config = load_config(config_file) + config.update(master_config['general']) + config.update(master_config['predict']) + sample = config['sample'] + data_dir = config['data_dir'] + setup = config['setup'] + # solve_context = daisy.Coordinate(master_config['solve']['context']) + setup_dir = os.path.abspath( + os.path.join(config['setups_dir'], setup)) + voxel_size, source_roi = get_source_roi(data_dir, sample) + predict_roi = source_roi + + # limit to specific frames, if given + if 'limit_to_roi_offset' in config or 'frames' in config: + if 'frames' in config: + frames = config['frames'] + logger.info("Limiting prediction to frames %s" % str(frames)) + begin, end = frames + frames_roi = daisy.Roi( + (begin, None, None, None), + (end - begin, None, None, None)) + predict_roi = predict_roi.intersect(frames_roi) + if 'limit_to_roi_offset' in config: + assert 'limit_to_roi_shape' in config,\ + "Must specify shape and offset in config file" + limit_to_roi = daisy.Roi( + daisy.Coordinate(config['limit_to_roi_offset']), + daisy.Coordinate(config['limit_to_roi_shape'])) + predict_roi = predict_roi.intersect(limit_to_roi) + # Given frames and rois are the prediction region, + # not the solution region + # predict_roi = target_roi.grow(solve_context, solve_context) + # predict_roi = predict_roi.intersect(source_roi) # get context and total input and output ROI with open(os.path.join(setup_dir, 'test_net_config.json'), 'r') as f: @@ -39,10 +67,6 @@ def predict_blockwise(linajea_config): net_input_size = daisy.Coordinate(net_input_size)*voxel_size net_output_size = daisy.Coordinate(net_output_size)*voxel_size context = (net_input_size - net_output_size)/2 - - # expand predict roi to multiple of block write_roi - predict_roi = predict_roi.snap_to_grid(net_output_size, mode='grow') - input_roi = predict_roi.grow(context, context) output_roi = predict_roi @@ -104,34 +128,53 @@ def predict_blockwise(linajea_config): # process block-wise - cf = [] - if linajea_config.predict.write_to_zarr: - cf.append(lambda b: check_function( - b, - 'predict_zarr', - data.db_name, - linajea_config.general.db_host)) - if linajea_config.predict.write_to_db: - cf.append(lambda b: check_function( - b, - 'predict_db', - data.db_name, - linajea_config.general.db_host)) - - # process block-wise - daisy.run_blockwise( - input_roi, - block_read_roi, - block_write_roi, - process_function=lambda: predict_worker(linajea_config), - check_function=lambda b: all([f(b) for f in cf]), - num_workers=linajea_config.predict.job.num_workers, - read_write_conflict=False, - max_retries=0, - fit='overhang') - - -def predict_worker(linajea_config): + if 'db_name' in config: + daisy.run_blockwise( + input_roi, + block_read_roi, + block_write_roi, + process_function=lambda: predict_worker( + config_file, + iteration), + check_function=lambda b: check_function( + b, + 'predict', + config['db_name'], + config['db_host']), + num_workers=config['num_workers'], + read_write_conflict=False, + max_retries=0, + fit='overhang') + else: + daisy.run_blockwise( + input_roi, + block_read_roi, + block_write_roi, + process_function=lambda: predict_worker( + config_file, + iteration), + num_workers=config['num_workers'], + read_write_conflict=False, + max_retries=0, + fit='overhang') + + +def predict_worker( + config_file, + iteration): + config = { + "singularity_image": 'linajea/linajea:v1.1', + "queue": 'slowpoke', + 'setups_dir': '../02_setups' + } + master_config = load_config(config_file) + config.update(master_config['general']) + config.update(master_config['predict']) + singularity_image = config['singularity_image'] + queue = config['queue'] + setups_dir = config['setups_dir'] + setup = config['setup'] + chargeback = config['lab'] worker_id = daisy.Context.from_env().worker_id worker_time = time.time() From 27aaa1ae78ebb9336b6d4422f5fd848607c42cbf Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Apr 2021 11:02:42 -0400 Subject: [PATCH 104/263] Revert "Update old git checkout that was on a deleted branch" This reverts commit 36db241bf63687fa180101a2416a72bc909aa534. Does not need to be reimplemented after config class merge --- requirements.txt | 2 +- singularity/Singularity | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index a3f7fa8..0c3004b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,5 +13,5 @@ sklearn -e git+https://github.com/funkelab/funlib.run#egg=funlib.run -e git+https://github.com/funkelab/funlib.learn.torch#egg=funlib.learn.torch -e git+https://github.com/funkelab/funlib.learn.tensorflow#egg=funlib.learn.tensorflow --e git+https://github.com/funkey/gunpowder@773b5578b01d16d34ef3cc466904ab876bba8bf1#egg=gunpowder +-e git+https://github.com/funkey/gunpowder@2ce2fdbee5c0bb5d2e12471e079a252f7fae54ea#egg=gunpowder -e git+https://github.com/funkelab/daisy@3d7826e7e4ab5844d55debac9bbb00b4e43a998b#egg=daisy diff --git a/singularity/Singularity b/singularity/Singularity index d3c6399..3abdc8e 100644 --- a/singularity/Singularity +++ b/singularity/Singularity @@ -30,7 +30,7 @@ pip install zarr GUNPOWDER_ROOT=/src/gunpowder GUNPOWDER_REPOSITORY=https://github.com/funkey/gunpowder.git -GUNPOWDER_REVISION=b7bd287e82553fa6d1d667a474e3e5457577060e +GUNPOWDER_REVISION=2ce2fdbee5c0bb5d2e12471e079a252f7fae54ea mkdir -p ${GUNPOWDER_ROOT} cd ${GUNPOWDER_ROOT} From 186f2b517e9825250ad1c51c686e01bd269c675d Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Apr 2021 11:03:33 -0400 Subject: [PATCH 105/263] Revert "Make matching threshold for mamut configurable" This reverts commit 6a3546afc417621b61e582650e6e5ab142345b0a. Will be reimplemented after config class merge --- .../mamut/mamut_matched_tracks_reader.py | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/linajea/visualization/mamut/mamut_matched_tracks_reader.py b/linajea/visualization/mamut/mamut_matched_tracks_reader.py index 0ccc744..138b971 100644 --- a/linajea/visualization/mamut/mamut_matched_tracks_reader.py +++ b/linajea/visualization/mamut/mamut_matched_tracks_reader.py @@ -13,8 +13,56 @@ def __init__(self, db_host): super(MamutReader, self).__init__() self.db_host = db_host - def read_data(self, data, is_tp=None): - (gt_tracks, matched_rec_tracks) = data + def read_data(self, data): + candidate_db_name = data['db_name'] + start_frame, end_frame = data['frames'] + gt_db_name = data['gt_db_name'] + assert end_frame > start_frame + roi = Roi((start_frame, 0, 0, 0), + (end_frame - start_frame, 1e10, 1e10, 1e10)) + if 'parameters_id' in data: + try: + int(data['parameters_id']) + selected_key = 'selected_' + str(data['parameters_id']) + except: + selected_key = data['parameters_id'] + else: + selected_key = None + db = linajea.CandidateDatabase( + candidate_db_name, self.db_host) + db.selected_key = selected_key + gt_db = linajea.CandidateDatabase(gt_db_name, self.db_host) + + print("Reading GT cells and edges in %s" % roi) + gt_subgraph = gt_db[roi] + gt_graph = linajea.tracking.TrackGraph(gt_subgraph, frame_key='t') + gt_tracks = list(gt_graph.get_tracks()) + print("Found %d GT tracks" % len(gt_tracks)) + + # tracks_to_xml(gt_cells, gt_tracks, 'linajea_gt.xml') + + print("Reading cells and edges in %s" % roi) + subgraph = db.get_selected_graph(roi) + graph = linajea.tracking.TrackGraph(subgraph, frame_key='t') + tracks = list(graph.get_tracks()) + print("Found %d tracks" % len(tracks)) + + if len(graph.nodes) == 0 or len(gt_graph.nodes) == 0: + logger.info("Didn't find gt or reconstruction - returning") + return [], [] + + m = linajea.evaluation.match_edges( + gt_graph, graph, + matching_threshold=20) + (edges_x, edges_y, edge_matches, edge_fps) = m + matched_rec_tracks = [] + for track in tracks: + for _, edge_index in edge_matches: + edge = edges_y[edge_index] + if track.has_edge(edge[0], edge[1]): + matched_rec_tracks.append(track) + break + logger.debug("found %d matched rec tracks" % len(matched_rec_tracks)) logger.info("Adding %d gt tracks" % len(gt_tracks)) track_id = 0 From 9510c57b0b5c80c425282a1f379b382fb5c36ce0 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Apr 2021 11:04:03 -0400 Subject: [PATCH 106/263] Revert "Do matching within mamut matched tracks reader" This reverts commit e161dbb118204e6ebdbf607d5efb43a85c1a70eb. Might be reimplemented after config class merge --- .../mamut/mamut_matched_tracks_reader.py | 76 +++---------------- 1 file changed, 12 insertions(+), 64 deletions(-) diff --git a/linajea/visualization/mamut/mamut_matched_tracks_reader.py b/linajea/visualization/mamut/mamut_matched_tracks_reader.py index 138b971..a558368 100644 --- a/linajea/visualization/mamut/mamut_matched_tracks_reader.py +++ b/linajea/visualization/mamut/mamut_matched_tracks_reader.py @@ -1,89 +1,37 @@ from __future__ import print_function, division, absolute_import import logging from .mamut_reader import MamutReader -import linajea -import linajea.evaluation -from daisy import Roi logger = logging.getLogger(__name__) class MamutMatchedTracksReader(MamutReader): - def __init__(self, db_host): + def __init__(self): super(MamutReader, self).__init__() - self.db_host = db_host def read_data(self, data): - candidate_db_name = data['db_name'] - start_frame, end_frame = data['frames'] - gt_db_name = data['gt_db_name'] - assert end_frame > start_frame - roi = Roi((start_frame, 0, 0, 0), - (end_frame - start_frame, 1e10, 1e10, 1e10)) - if 'parameters_id' in data: - try: - int(data['parameters_id']) - selected_key = 'selected_' + str(data['parameters_id']) - except: - selected_key = data['parameters_id'] - else: - selected_key = None - db = linajea.CandidateDatabase( - candidate_db_name, self.db_host) - db.selected_key = selected_key - gt_db = linajea.CandidateDatabase(gt_db_name, self.db_host) - - print("Reading GT cells and edges in %s" % roi) - gt_subgraph = gt_db[roi] - gt_graph = linajea.tracking.TrackGraph(gt_subgraph, frame_key='t') - gt_tracks = list(gt_graph.get_tracks()) - print("Found %d GT tracks" % len(gt_tracks)) - - # tracks_to_xml(gt_cells, gt_tracks, 'linajea_gt.xml') - - print("Reading cells and edges in %s" % roi) - subgraph = db.get_selected_graph(roi) - graph = linajea.tracking.TrackGraph(subgraph, frame_key='t') - tracks = list(graph.get_tracks()) - print("Found %d tracks" % len(tracks)) - - if len(graph.nodes) == 0 or len(gt_graph.nodes) == 0: - logger.info("Didn't find gt or reconstruction - returning") - return [], [] - - m = linajea.evaluation.match_edges( - gt_graph, graph, - matching_threshold=20) - (edges_x, edges_y, edge_matches, edge_fps) = m + (gt_tracks, rec_tracks, track_matches) = data + matched_rec_tracks_indexes = [rec for gt, rec in track_matches + if gt < len(gt_tracks)] matched_rec_tracks = [] - for track in tracks: - for _, edge_index in edge_matches: - edge = edges_y[edge_index] - if track.has_edge(edge[0], edge[1]): - matched_rec_tracks.append(track) - break - logger.debug("found %d matched rec tracks" % len(matched_rec_tracks)) + logger.debug(track_matches) + logger.debug("Matched Rec Tracks: %s" % matched_rec_tracks_indexes) + for index in matched_rec_tracks_indexes: + matched_rec_tracks.append(rec_tracks[index]) logger.info("Adding %d gt tracks" % len(gt_tracks)) track_id = 0 cells = [] tracks = [] for track in gt_tracks: - result = self.add_track(track, track_id, group=0) - print(result[0]) - if result is None or len(result[0]) == 0: - continue - track_cells, track = result + track_cells, track = self.add_track(track, track_id, group=0) cells += track_cells tracks.append(track) track_id += 1 logger.info("Adding %d matched rec tracks" % len(matched_rec_tracks)) for track in matched_rec_tracks: - result = self.add_track(track, track_id, group=1) - if result is None: - continue - track_cells, track = result + track_cells, track = self.add_track(track, track_id, group=1) cells += track_cells tracks.append(track) track_id += 1 @@ -93,11 +41,11 @@ def read_data(self, data): def add_track(self, track, track_id, group): if len(track.nodes) == 0: logger.info("Track has no nodes. Skipping") - return [], {} + return [], [] if len(track.edges) == 0: logger.info("Track has no edges. Skipping") - return [], {} + return [], [] cells = [] invalid_cells = [] From 20d72bc37d1189778d91a36cea17bc10aeafe451 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Apr 2021 11:07:40 -0400 Subject: [PATCH 107/263] Remove pylp from requirements.txt --- README | 8 ++++++++ requirements.txt | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README b/README index 20bf43a..fd769ab 100644 --- a/README +++ b/README @@ -15,6 +15,14 @@ supporting code to run those experiments, and is where most of the interesting functionality resides, other than network architecture definition. +INSTALLATION + - create a conda environment + - pip or conda install numpy, cython + - pip install -r requirements.txt + - conda install -c funkey pylp + - pip install . + + VERSIONING The current stable version is v1.3, and the 1.4-dev branch has the most cutting edge functionality. We will do our best to release minor version diff --git a/requirements.txt b/requirements.txt index 0c3004b..8dc201e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ cython networkx pymongo -pylp toml scipy pandas From f2da6af26f2d4f30080b519c4cf05f5aa05a621d Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Apr 2021 11:24:20 -0400 Subject: [PATCH 108/263] Reimplement "Add flag to use pv distance in extract edges" (fbcd47e) --- linajea/config/extract.py | 1 + linajea/process_blockwise/extract_edges_blockwise.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/linajea/config/extract.py b/linajea/config/extract.py index 76741d3..24a77be 100644 --- a/linajea/config/extract.py +++ b/linajea/config/extract.py @@ -21,3 +21,4 @@ class ExtractConfig: block_size = attr.ib(type=List[int]) job = attr.ib(converter=ensure_cls(JobConfig)) context = attr.ib(type=List[int], default=None) + use_pv_distance = attr.ib(type=bool, default=False) diff --git a/linajea/process_blockwise/extract_edges_blockwise.py b/linajea/process_blockwise/extract_edges_blockwise.py index 2f5760e..7587b7c 100644 --- a/linajea/process_blockwise/extract_edges_blockwise.py +++ b/linajea/process_blockwise/extract_edges_blockwise.py @@ -161,9 +161,14 @@ def extract_edges_in_block( nex_cell_center = nex_cell[1] nex_parent_center = nex_cell_center + nex_cell[2] - pre_cells_indices = pre_kd_tree.query_ball_point( - nex_cell_center, - edge_move_threshold) + if linajea_config.extract.use_pv_distance: + pre_cells_indices = pre_kd_tree.query_ball_point( + nex_parent_center, + edge_move_threshold) + else: + pre_cells_indices = pre_kd_tree.query_ball_point( + nex_cell_center, + edge_move_threshold) pre_cells = [all_pre_cells[i] for i in pre_cells_indices] logger.debug( From 813fb5ccb25194286f504d97c1e93cd06315a2b2 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Apr 2021 11:27:23 -0400 Subject: [PATCH 109/263] Reimplement "Use valid padding for prediction" (ce3e67957125e92) --- .../process_blockwise/predict_blockwise.py | 79 ++++++++----------- 1 file changed, 32 insertions(+), 47 deletions(-) diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index ad42d9e..0b99569 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -67,6 +67,10 @@ def predict_blockwise( net_input_size = daisy.Coordinate(net_input_size)*voxel_size net_output_size = daisy.Coordinate(net_output_size)*voxel_size context = (net_input_size - net_output_size)/2 + + # expand predict roi to multiple of block write_roi + predict_roi = predict_roi.snap_to_grid(net_output_size, mode='grow') + input_roi = predict_roi.grow(context, context) output_roi = predict_roi @@ -128,53 +132,34 @@ def predict_blockwise( # process block-wise - if 'db_name' in config: - daisy.run_blockwise( - input_roi, - block_read_roi, - block_write_roi, - process_function=lambda: predict_worker( - config_file, - iteration), - check_function=lambda b: check_function( - b, - 'predict', - config['db_name'], - config['db_host']), - num_workers=config['num_workers'], - read_write_conflict=False, - max_retries=0, - fit='overhang') - else: - daisy.run_blockwise( - input_roi, - block_read_roi, - block_write_roi, - process_function=lambda: predict_worker( - config_file, - iteration), - num_workers=config['num_workers'], - read_write_conflict=False, - max_retries=0, - fit='overhang') - - -def predict_worker( - config_file, - iteration): - config = { - "singularity_image": 'linajea/linajea:v1.1', - "queue": 'slowpoke', - 'setups_dir': '../02_setups' - } - master_config = load_config(config_file) - config.update(master_config['general']) - config.update(master_config['predict']) - singularity_image = config['singularity_image'] - queue = config['queue'] - setups_dir = config['setups_dir'] - setup = config['setup'] - chargeback = config['lab'] + cf = [] + if linajea_config.predict.write_to_zarr: + cf.append(lambda b: check_function( + b, + 'predict_zarr', + data.db_name, + linajea_config.general.db_host)) + if linajea_config.predict.write_to_db: + cf.append(lambda b: check_function( + b, + 'predict_db', + data.db_name, + linajea_config.general.db_host)) + + # process block-wise + daisy.run_blockwise( + input_roi, + block_read_roi, + block_write_roi, + process_function=lambda: predict_worker(linajea_config), + check_function=lambda b: all([f(b) for f in cf]), + num_workers=linajea_config.predict.job.num_workers, + read_write_conflict=False, + max_retries=0, + fit='valid') + + +def predict_worker(linajea_config): worker_id = daisy.Context.from_env().worker_id worker_time = time.time() From b3c6ef7d73dd137794c8e10e7594e3be3b5518df Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Apr 2021 11:29:26 -0400 Subject: [PATCH 110/263] Implement matching inside of the mamut matched tracks reader --- .../mamut/mamut_matched_tracks_reader.py | 77 ++++++++++++++++--- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/linajea/visualization/mamut/mamut_matched_tracks_reader.py b/linajea/visualization/mamut/mamut_matched_tracks_reader.py index a558368..adafc42 100644 --- a/linajea/visualization/mamut/mamut_matched_tracks_reader.py +++ b/linajea/visualization/mamut/mamut_matched_tracks_reader.py @@ -1,37 +1,90 @@ from __future__ import print_function, division, absolute_import import logging from .mamut_reader import MamutReader +import linajea +import linajea.evaluation +from daisy import Roi logger = logging.getLogger(__name__) class MamutMatchedTracksReader(MamutReader): - def __init__(self): + def __init__(self, db_host): super(MamutReader, self).__init__() + self.db_host = db_host def read_data(self, data): - (gt_tracks, rec_tracks, track_matches) = data - matched_rec_tracks_indexes = [rec for gt, rec in track_matches - if gt < len(gt_tracks)] + candidate_db_name = data['db_name'] + start_frame, end_frame = data['frames'] + matching_threshold = data.get('matching_threshold', 20) + gt_db_name = data['gt_db_name'] + assert end_frame > start_frame + roi = Roi((start_frame, 0, 0, 0), + (end_frame - start_frame, 1e10, 1e10, 1e10)) + if 'parameters_id' in data: + try: + int(data['parameters_id']) + selected_key = 'selected_' + str(data['parameters_id']) + except: + selected_key = data['parameters_id'] + else: + selected_key = None + db = linajea.CandidateDatabase( + candidate_db_name, self.db_host) + db.selected_key = selected_key + gt_db = linajea.CandidateDatabase(gt_db_name, self.db_host) + + print("Reading GT cells and edges in %s" % roi) + gt_subgraph = gt_db[roi] + gt_graph = linajea.tracking.TrackGraph(gt_subgraph, frame_key='t') + gt_tracks = list(gt_graph.get_tracks()) + print("Found %d GT tracks" % len(gt_tracks)) + + # tracks_to_xml(gt_cells, gt_tracks, 'linajea_gt.xml') + + print("Reading cells and edges in %s" % roi) + subgraph = db.get_selected_graph(roi) + graph = linajea.tracking.TrackGraph(subgraph, frame_key='t') + tracks = list(graph.get_tracks()) + print("Found %d tracks" % len(tracks)) + + if len(graph.nodes) == 0 or len(gt_graph.nodes) == 0: + logger.info("Didn't find gt or reconstruction - returning") + return [], [] + + m = linajea.evaluation.match_edges( + gt_graph, graph, + matching_threshold=matching_threshold) + (edges_x, edges_y, edge_matches, edge_fps) = m matched_rec_tracks = [] - logger.debug(track_matches) - logger.debug("Matched Rec Tracks: %s" % matched_rec_tracks_indexes) - for index in matched_rec_tracks_indexes: - matched_rec_tracks.append(rec_tracks[index]) + for track in tracks: + for _, edge_index in edge_matches: + edge = edges_y[edge_index] + if track.has_edge(edge[0], edge[1]): + matched_rec_tracks.append(track) + break + logger.debug("found %d matched rec tracks" % len(matched_rec_tracks)) logger.info("Adding %d gt tracks" % len(gt_tracks)) track_id = 0 cells = [] tracks = [] for track in gt_tracks: - track_cells, track = self.add_track(track, track_id, group=0) + result = self.add_track(track, track_id, group=0) + print(result[0]) + if result is None or len(result[0]) == 0: + continue + track_cells, track = result cells += track_cells tracks.append(track) track_id += 1 logger.info("Adding %d matched rec tracks" % len(matched_rec_tracks)) for track in matched_rec_tracks: - track_cells, track = self.add_track(track, track_id, group=1) + result = self.add_track(track, track_id, group=1) + if result is None: + continue + track_cells, track = result cells += track_cells tracks.append(track) track_id += 1 @@ -41,11 +94,11 @@ def read_data(self, data): def add_track(self, track, track_id, group): if len(track.nodes) == 0: logger.info("Track has no nodes. Skipping") - return [], [] + return None if len(track.edges) == 0: logger.info("Track has no edges. Skipping") - return [], [] + return None cells = [] invalid_cells = [] From 67cf0269517e7e1996eafdfea8c0dc24eaec5dd6 Mon Sep 17 00:00:00 2001 From: Peter H Date: Mon, 26 Apr 2021 16:13:19 +0200 Subject: [PATCH 111/263] fix copy-paste error in write ssvm --- linajea/tracking/solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index 08be2bf..c036752 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -344,7 +344,7 @@ def _continuation_costs(self, node, file_weight_or_constant): if self.parameters.cell_cycle_key is None: if self.write_struct_svm: file_weight_or_constant.write("{} 1\n".format( - self.node_child[node])) + self.node_continuation[node])) return 0 continuation_costs = ( # self.graph.nodes[node][self.parameters.cell_cycle_key][2] * From d8e99ff53f09a3728e701e7fec609ed29b32f9c3 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Jul 2021 13:55:28 -0400 Subject: [PATCH 112/263] Add option to compute validation score and set window size for eval --- linajea/config/evaluate.py | 3 ++ linajea/evaluation/evaluate.py | 8 +++- linajea/evaluation/evaluate_setup.py | 4 +- linajea/evaluation/evaluator.py | 57 +++++++++++++++++++++++++++- linajea/evaluation/report.py | 1 + tests/test_evaluation.py | 16 ++++++++ 6 files changed, 84 insertions(+), 5 deletions(-) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 02e08c6..2435de9 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -11,6 +11,9 @@ class EvaluateParametersConfig: matching_threshold = attr.ib(type=int) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + validation_score = attr.ib(type=bool, default=False) + window_size = attr.ib(type=int, default=50) + # deprecated frames = attr.ib(type=List[int], default=None) # deprecated diff --git a/linajea/evaluation/evaluate.py b/linajea/evaluation/evaluate.py index e29558e..58a2dfe 100644 --- a/linajea/evaluation/evaluate.py +++ b/linajea/evaluation/evaluate.py @@ -9,7 +9,9 @@ def evaluate( gt_track_graph, rec_track_graph, matching_threshold, - sparse): + sparse, + validation_score=False, + window_size=50): ''' Performs both matching and evaluation on the given gt and reconstructed tracks, and returns a Report with the results. @@ -30,5 +32,7 @@ def evaluate( rec_track_graph, edge_matches, unselected_potential_matches, - sparse=sparse) + sparse=sparse, + validation_score=validation_score, + window_size=window_size) return evaluator.evaluate() diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index 2ad3803..d93eadf 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -78,7 +78,9 @@ def evaluate_setup(linajea_config): gt_track_graph, track_graph, matching_threshold=linajea_config.evaluate.parameters.matching_threshold, - sparse=linajea_config.general.sparse) + sparse=linajea_config.general.sparse, + validation_score=linajea_config.evaluate.parameters.validation_score, + window_size=linajea_config.evaluate.parameters.window_size) logger.info("Done evaluating results for %d. Saving results to mongo." % parameters_id) diff --git a/linajea/evaluation/evaluator.py b/linajea/evaluation/evaluator.py index 5c17a14..ef6921f 100644 --- a/linajea/evaluation/evaluator.py +++ b/linajea/evaluation/evaluator.py @@ -41,7 +41,9 @@ def __init__( rec_track_graph, edge_matches, unselected_potential_matches, - sparse=True + sparse=True, + validation_score=False, + window_size=50, ): self.report = Report() @@ -50,6 +52,8 @@ def __init__( self.edge_matches = edge_matches self.unselected_potential_matches = unselected_potential_matches self.sparse = sparse + self.validation_score = validation_score + self.window_size = window_size # get tracks self.gt_tracks = gt_track_graph.get_tracks() @@ -97,7 +101,9 @@ def evaluate(self): self.get_fn_divisions() self.get_f_score() self.get_aeftl_and_erl() - self.get_validation_score() + self.get_perfect_segments(self.window_size) + if self.validation_score: + self.get_validation_score() return self.report def get_fp_edges(self): @@ -365,6 +371,53 @@ def get_aeftl_and_erl(self): )) / self.gt_track_graph.number_of_edges() self.report.set_aeftl_and_erl(aeftl, erl) + def get_perfect_segments(self, window_size): + ''' Compute the percent of gt track segments that are correctly + reconstructed using a sliding window of each size from 1 to t. Store + the dictionary from window size to (# correct, total #) in self.report + ''' + logger.info("Getting perfect segments") + total_segments = {} + correct_segments = {} + for i in range(1, window_size + 1): + total_segments[i] = 0 + correct_segments[i] = 0 + + for gt_track in self.gt_tracks: + for start_node in gt_track.nodes(): + start_edges = gt_track.next_edges(start_node) + for start_edge in start_edges: + frames = 1 + correct = True + current_nodes = [start_node] + next_edges = [start_edge] + while len(next_edges) > 0: + if correct: + # check current node and next edge + for current_node in current_nodes: + if current_node != start_node: + if 'IS' in self.gt_track_graph.nodes[current_node] or\ + 'FP_D' in self.gt_track_graph.nodes[current_node]: + correct = False + for next_edge in next_edges: + if 'FN' in self.gt_track_graph.get_edge_data(*next_edge): + correct = False + # update segment counts + total_segments[frames] += 1 + if correct: + correct_segments[frames] += 1 + # update loop variables + frames += 1 + current_nodes = [u for u, v in next_edges] + next_edges = gt_track.next_edges(current_nodes) + if frames > window_size: + break + + result = {} + for i in range(1, window_size + 1): + result[str(i)] = (correct_segments[i], total_segments[i]) + self.report.correct_segments = result + @staticmethod def check_track_validity(track_graph): # 0 or 1 parent per node diff --git a/linajea/evaluation/report.py b/linajea/evaluation/report.py index 3abb403..2702dd3 100644 --- a/linajea/evaluation/report.py +++ b/linajea/evaluation/report.py @@ -31,6 +31,7 @@ def __init__(self): self.f_score = None self.aeftl = None self.erl = None + self.correct_segments = None self.validation_score = None # FAILURE POINTS diff --git a/tests/test_evaluation.py b/tests/test_evaluation.py index c17a340..a882a08 100644 --- a/tests/test_evaluation.py +++ b/tests/test_evaluation.py @@ -86,6 +86,9 @@ def test_perfect_evaluation(self): self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 1.0) self.assertAlmostEqual(scores.f_score, 1.0) + self.assertEqual(scores.correct_segments[1], (3, 3)) + self.assertEqual(scores.correct_segments[2], (2, 2)) + self.assertEqual(scores.correct_segments[3], (1, 1)) self.delete_db() def test_imperfect_evaluation(self): @@ -115,6 +118,9 @@ def test_imperfect_evaluation(self): self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 2./3) self.assertAlmostEqual(scores.f_score, 4./5) + self.assertEqual(scores.correct_segments[1], (2, 3)) + self.assertEqual(scores.correct_segments[2], (0, 2)) + self.assertEqual(scores.correct_segments[3], (0, 1)) self.delete_db() def test_fn_division_evaluation(self): @@ -146,6 +152,10 @@ def test_fn_division_evaluation(self): self.assertEqual(scores.fp_divisions, 0) self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 5./6) + self.assertEqual(scores.correct_segments[1], (5, 6)) + self.assertEqual(scores.correct_segments[2], (2, 4)) + self.assertEqual(scores.correct_segments[3], (0, 2)) + self.assertEqual(scores.correct_segments[4], (0, 1)) self.delete_db() def test_fn_division_evaluation2(self): @@ -179,6 +189,9 @@ def test_fn_division_evaluation2(self): self.assertEqual(scores.fp_divisions, 0) self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 4./5) + self.assertEqual(scores.correct_segments[1], (4, 5)) + self.assertEqual(scores.correct_segments[2], (2, 3)) + self.assertEqual(scores.correct_segments[3], (0, 1)) self.delete_db() def test_fn_division_evaluation3(self): @@ -239,6 +252,9 @@ def test_fp_division_evaluation(self): self.assertEqual(scores.fp_divisions, 1) self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 1.0) + self.assertEqual(scores.correct_segments[1], (5, 5)) + self.assertEqual(scores.correct_segments[2], (2, 3)) + self.assertEqual(scores.correct_segments[3], (0, 1)) self.delete_db() def test_fp_division_evaluation_at_beginning_of_gt(self): From 83400f0a030032f9506d4015fc92b008e7389edc Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Jul 2021 14:01:48 -0400 Subject: [PATCH 113/263] Use db_name in analysis functions --- linajea/evaluation/__init__.py | 3 +- linajea/evaluation/analyze_results.py | 54 +++++++++++++-------------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/linajea/evaluation/__init__.py b/linajea/evaluation/__init__.py index 5bd8b64..097cf36 100644 --- a/linajea/evaluation/__init__.py +++ b/linajea/evaluation/__init__.py @@ -10,7 +10,8 @@ get_results_sorted, get_best_result_with_config, get_result_id, get_best_result_per_setup, get_tgmm_results, - get_best_tgmm_result) + get_best_tgmm_result, + get_greedy) from .analyze_candidates import ( get_node_recall, get_edge_recall, diff --git a/linajea/evaluation/analyze_results.py b/linajea/evaluation/analyze_results.py index 7d7cf39..043f4aa 100644 --- a/linajea/evaluation/analyze_results.py +++ b/linajea/evaluation/analyze_results.py @@ -24,8 +24,7 @@ def get_sample_from_setup(setup): def get_result( - setup, - region, + db_name, tracking_parameters, db_host, frames=None, @@ -37,9 +36,6 @@ def get_result( object. tracking_parameters can be a dict or a TrackingParameters object''' - if not sample: - sample = get_sample_from_setup(setup) - db_name = '_'.join(['linajea', sample, setup, region, iteration]) candidate_db = CandidateDatabase(db_name, db_host, 'r') if isinstance(tracking_parameters, dict): tracking_parameters = TrackingParameters(**tracking_parameters) @@ -50,15 +46,23 @@ def get_result( return result +def get_greedy( + db_name, + db_host, + key="selected_greedy", + frames=None): + + candidate_db = CandidateDatabase(db_name, db_host, 'r') + result = candidate_db.get_score(key, frames=frames) + if result is None: + logger.error("Greedy result for db %d is None", db_name) + return result + + def get_tgmm_results( - region, + db_name, db_host, - sample, frames=None): - if region is None: - db_name = '_'.join(['linajea', sample, 'tgmm']) - else: - db_name = '_'.join(['linajea', sample, 'tgmm', region]) candidate_db = CandidateDatabase(db_name, db_host, 'r') results = candidate_db.get_scores(frames=frames) if results is None or len(results) == 0: @@ -68,9 +72,8 @@ def get_tgmm_results( def get_best_tgmm_result( - region, + db_name, db_host, - sample, frames=None, score_columns=None, score_weights=None): @@ -79,10 +82,10 @@ def get_best_tgmm_result( 'fp_divisions', 'fn_divisions'] if not score_weights: score_weights = [1.]*len(score_columns) - results_df = get_tgmm_results(region, db_host, sample, frames=frames) + results_df = get_tgmm_results(db_name, db_host, frames=frames) if results_df is None: - logger.warn("No TGMM results for region %s, sample %s, and frames %s" - % (region, sample, str(frames))) + logger.warn("No TGMM results for db %s, and frames %s" + % (db_name, str(frames))) return None results_df['sum_errors'] = sum([results_df[col]*weight for col, weight in zip(score_columns, score_weights)]) @@ -93,8 +96,7 @@ def get_best_tgmm_result( def get_results( - setup, - region, + db_name, db_host, sample=None, iteration='400000', @@ -103,9 +105,6 @@ def get_results( ''' Gets the scores, statistics, and parameters for all grid search configurations run for the given setup and region. Returns a pandas dataframe with one row per configuration.''' - if not sample: - sample = get_sample_from_setup(setup) - db_name = '_'.join(['linajea', sample, setup, region, iteration]) candidate_db = CandidateDatabase(db_name, db_host, 'r') scores = candidate_db.get_scores(frames=frames, filters=filter_params) dataframe = pandas.DataFrame(scores) @@ -117,7 +116,7 @@ def get_results( return dataframe -def get_best_result(setup, region, db_host, +def get_best_result(db_name, db_host, sample=None, iteration='400000', frames=None, @@ -133,7 +132,7 @@ def get_best_result(setup, region, db_host, 'fp_divisions', 'fn_divisions'] if not score_weights: score_weights = [1.]*len(score_columns) - results_df = get_results(setup, region, db_host, + results_df = get_results(db_name, db_host, frames=frames, sample=sample, iteration=iteration, filter_params=filter_params) @@ -146,20 +145,19 @@ def get_best_result(setup, region, db_host, best_result[key] = value.item() except AttributeError: pass - best_result['setup'] = setup return best_result -def get_best_result_per_setup(setups, region, db_host, +def get_best_result_per_setup(db_names, db_host, frames=None, sample=None, iteration='400000', filter_params=None, score_columns=None, score_weights=None): - ''' Returns the best result for each setup in setups + ''' Returns the best result for each db in db_names according to the sum of errors in score_columns, with optional weighting, sorted from best to worst (lowest to highest sum errors)''' best_results = [] - for setup in setups: - best = get_best_result(setup, region, db_host, + for db_name in db_names: + best = get_best_result(db_name, db_host, frames=frames, sample=sample, iteration=iteration, filter_params=filter_params, From 1bc369c0ec86a173d626ef8f339f5ef13817cab7 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Jul 2021 14:12:09 -0400 Subject: [PATCH 114/263] Load graph incrementally in greedy tracking --- linajea/tracking/greedy_track.py | 130 +++++++++++++++++++++++++------ 1 file changed, 108 insertions(+), 22 deletions(-) diff --git a/linajea/tracking/greedy_track.py b/linajea/tracking/greedy_track.py index e022611..947789a 100644 --- a/linajea/tracking/greedy_track.py +++ b/linajea/tracking/greedy_track.py @@ -1,49 +1,126 @@ -from .track_graph import TrackGraph import logging import networkx as nx +from linajea import CandidateDatabase +from daisy import Roi +from .track_graph import TrackGraph logger = logging.getLogger(__name__) +def load_graph( + cand_db, + roi, + selected_key): + ''' + Args: + cand_db (`linajea.CandidateDatabase`) + Candidate Database from which to load graph + + roi (`daisy.Roi`) + Roi to get data from. + + selected_key (`str`): + Edge attribute used to store selection. Should be set to false by + default + + Returns: A new nx.DiGraph with the data from cand_db in region roi. + ''' + edge_attributes = ['distance', 'prediction_distance'] + graph = cand_db.get_graph(roi, edge_attrs=edge_attributes) + # set selected key to false + nx.set_edge_attributes(graph, False, selected_key) + return graph + + def greedy_track( - graph, + db_name, + db_host, selected_key, cell_indicator_threshold, metric='prediction_distance', frame_key='t', - allow_new_tracks=True): - if graph.number_of_nodes() == 0: - logger.info("No nodes in graph - skipping solving step") - return - nx.set_edge_attributes(graph, False, selected_key) - track_graph = TrackGraph(graph_data=graph, - frame_key=frame_key, - roi=graph.roi) - start_frame, end_frame = track_graph.get_frames() + allow_new_tracks=True, + roi=None): + cand_db = CandidateDatabase(db_name, db_host, 'r+') + total_roi = cand_db.get_nodes_roi() + if roi is not None: + total_roi = roi.intersect(total_roi) + + start_frame = total_roi.get_offset()[0] + end_frame = start_frame + total_roi.get_shape()[0] + logger.info("Tracking from frame %d to frame %d", end_frame, start_frame) + step = 10 + selected_prev_nodes = set() + first = True + for section_end in range(end_frame, start_frame, -1*step): + section_begin = section_end - step + frames_roi = Roi((section_begin, None, None, None), + (step, None, None, None)) + section_roi = total_roi.intersect(frames_roi) + logger.info("Greedy tracking in section %s", str(section_roi)) + selected_prev_nodes = track_section( + cand_db, + section_roi, + selected_key, + cell_indicator_threshold, + selected_prev_nodes, + metric=metric, + frame_key=frame_key, + allow_new_tracks=allow_new_tracks, + first=first) + first = False + logger.debug("Done tracking in section %s", str(section_roi)) + + +def track_section( + cand_db, + roi, + selected_key, + cell_indicator_threshold, + selected_prev_nodes, + metric='prediction_distance', + frame_key='t', + allow_new_tracks=True, + first=False): + # this function solves this whole section and stores the result, and + # returns the node ids in the preceeding frame (before roi!) that were + # selected + + graph = load_graph(cand_db, roi, selected_key) + track_graph = TrackGraph(graph_data=graph, frame_key=frame_key, roi=roi) + start_frame = roi.get_offset()[0] + end_frame = start_frame + roi.get_shape()[0] - 1 + + for frame in range(end_frame, start_frame - 1, -1): + logger.debug("Processing frame %d", frame) - selected_prev_nodes = [] - for frame in range(end_frame, start_frame + 1, -1): # find "seed" cells in frame seed_candidates = track_graph.cells_by_frame(frame) - seeds = [node for node in seed_candidates - if graph.nodes[node]['score'] > cell_indicator_threshold] + if len(selected_prev_nodes) > 0: + assert [p in seed_candidates for p in selected_prev_nodes],\ + "previously selected nodes are not contained in current frame!" + seeds = set([node for node in seed_candidates + if graph.nodes[node]['score'] > cell_indicator_threshold]) + logger.debug("Found %d potential seeds in frame %d", len(seeds), frame) # use only new (not previously selected) nodes to seed new tracks - seeds = [s for s in seeds if s not in selected_prev_nodes] + seeds = seeds - selected_prev_nodes + logger.debug("Found %d seeds in frame %d", len(seeds), frame) - if frame == end_frame: + if first: # in this special case, all seeds are treated as selected selected_prev_nodes = seeds - seeds = [] + seeds = set() candidate_edges = [] - selected_next_nodes = [] + selected_next_nodes = set() # pick the shortest edges greedily for the set of previously selected # nodes, with tree constraint (allow divisions) for selected_prev in selected_prev_nodes: candidate_edges.extend(graph.out_edges(selected_prev, data=True)) - logger.debug("Sorting edges in frame %d", frame) + logger.debug("Sorting %d candidate edges in frame %d", + len(candidate_edges), frame) sorted_edges = sorted(candidate_edges, key=lambda e: e[2][metric]) @@ -65,7 +142,9 @@ def greedy_track( continue graph.edges[(u, v)][selected_key] = True - selected_next_nodes.append(v) + selected_next_nodes.add(v) + + logger.debug("Selected %d continuing edges", len(selected_next_nodes)) if allow_new_tracks: # pick the shortest edges greedily for the set of new possible @@ -93,6 +172,13 @@ def greedy_track( continue graph.edges[(u, v)][selected_key] = True - selected_next_nodes.append(v) + selected_next_nodes.add(v) + logger.debug("Selected %d total nodes in next frame", + len(selected_next_nodes)) selected_prev_nodes = selected_next_nodes + logger.info("updating edges in roi %s" % roi) + graph.update_edge_attrs( + roi, + attributes=selected_key) + return selected_prev_nodes From 16dfa25490c82baa226bac4bab5d522fe0048809 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Jul 2021 14:19:08 -0400 Subject: [PATCH 115/263] Don't assume params_id to be int in cand db logging --- linajea/candidate_database.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index 44b6c5e..f94b774 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -294,8 +294,8 @@ def write_score(self, parameters_id, report, eval_params=None): ''' parameters = self.get_parameters(parameters_id) if parameters is None: - logger.warning("No parameters with id %d. Saving with key only", - parameters_id) + logger.warning("No parameters with id %s. Saving with key only", + str(parameters_id)) self._MongoDbGraphProvider__connect() self._MongoDbGraphProvider__open_db() From e5d3f2267a9406ac2c6a7c2830de3434f74eae03 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Jul 2021 14:19:24 -0400 Subject: [PATCH 116/263] Use parse_tracks in mamut file reader --- linajea/visualization/mamut/mamut_file_reader.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/linajea/visualization/mamut/mamut_file_reader.py b/linajea/visualization/mamut/mamut_file_reader.py index ad152df..2d11995 100644 --- a/linajea/visualization/mamut/mamut_file_reader.py +++ b/linajea/visualization/mamut/mamut_file_reader.py @@ -1,4 +1,5 @@ from .mamut_reader import MamutReader +from linajea import parse_tracks_file import networkx as nx import logging @@ -8,15 +9,14 @@ class MamutFileReader(MamutReader): def read_data(self, data): filename = data['filename'] + locations, track_info = parse_tracks_file(filename) graph = nx.DiGraph() - with open(filename, 'r') as f: - for line in f.readlines(): - tokens = line.strip().split() - tokens = [int(t) for t in tokens] - t, z, y, x, node_id, parent_id, track_id = tokens - graph.add_node(node_id, position=[t, z, y, x]) - if parent_id != -1: - graph.add_edge(node_id, parent_id) + for loc, info in zip(locations, track_info): + node_id = info[0] + parent_id = info[1] + graph.add_node(node_id, position=loc.astype(int)) + if parent_id != -1: + graph.add_edge(node_id, parent_id) logger.info("Graph has %d nodes and %d edges" % (len(graph.nodes), len(graph.edges))) From fd205975c77234a6ee23add9ee4d308d231f0406 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 24 May 2021 11:35:01 -0400 Subject: [PATCH 117/263] Add jupyter notebook and jupytext to dev requirements --- requirements-dev.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index f604537..79fc77f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,5 @@ pytest pytest-cov flake8 +jupyter +jupytext From 8b3a9cda301eaef458c32c1d8fbef52b686fa01d Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Wed, 21 Apr 2021 15:48:38 -0400 Subject: [PATCH 118/263] Check each parameters id in before solving --- linajea/process_blockwise/solve_blockwise.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index dee0657..3d2f36d 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -130,6 +130,20 @@ def solve_in_block(linajea_config, step_name = 'solve_' + str(_id) logger.debug("Solving in block %s", block) + done_indices = [] + for index, pid in enumerate(parameters_id): + name = 'solve_' + str(pid) + if check_function_all_blocks(name, db_name, db_host): + logger.info("Params with id %d already completed. Removing", pid) + done_indices.append(index) + for index in done_indices[::-1]: + del parameters_id[index] + del parameters[index] + + if len(parameters) == 0: + logger.info("All parameters already completed. Exiting") + return 0 + if solution_roi: # Limit block to source_roi logger.debug("Block write roi: %s", block.write_roi) From 4f77985cf2a3e72a7a6c58656a7ad9a94ec974aa Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Mon, 19 Jul 2021 13:42:03 -0400 Subject: [PATCH 119/263] Update gunpowder version in requirements --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8dc201e..95db90d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,5 +12,5 @@ sklearn -e git+https://github.com/funkelab/funlib.run#egg=funlib.run -e git+https://github.com/funkelab/funlib.learn.torch#egg=funlib.learn.torch -e git+https://github.com/funkelab/funlib.learn.tensorflow#egg=funlib.learn.tensorflow --e git+https://github.com/funkey/gunpowder@2ce2fdbee5c0bb5d2e12471e079a252f7fae54ea#egg=gunpowder +-e git+https://github.com/funkey/gunpowder@6ffafcfdd3f7c7cdb61eedcb325814804dff4b2d#egg=gunpowder -e git+https://github.com/funkelab/daisy@3d7826e7e4ab5844d55debac9bbb00b4e43a998b#egg=daisy From 380d61ff25e4313fffaebfe63b84cdc8a23d6783 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 3 Aug 2021 15:50:49 -0400 Subject: [PATCH 120/263] Allow local prediction in predict_blockwise --- linajea/config/job.py | 1 + linajea/process_blockwise/predict_blockwise.py | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/linajea/config/job.py b/linajea/config/job.py index b6b23e5..109f4a6 100644 --- a/linajea/config/job.py +++ b/linajea/config/job.py @@ -7,3 +7,4 @@ class JobConfig: queue = attr.ib(type=str) lab = attr.ib(type=str, default=None) singularity_image = attr.ib(type=str, default=None) + local = attr.ib(type=bool, default=False) diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index 0b99569..f5ddcff 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -176,10 +176,16 @@ def predict_worker(linajea_config): path_to_script = linajea_config.predict.path_to_script_db_from_zarr else: path_to_script = linajea_config.predict.path_to_script - cmd = run( - command='python -u %s --config %s' % ( - path_to_script, - linajea_config.path), + + command = 'python -u %s --config %s' % ( + path_to_script, + linajea_config.path) + + if job.local: + cmd = [command] + else: + cmd = run( + command=command, queue=job.queue, num_gpus=1, num_cpus=linajea_config.predict.processes_per_worker, @@ -188,9 +194,11 @@ def predict_worker(linajea_config): execute=False, expand=False, flags=['-P ' + job.lab] if job.lab is not None else None - ) + ) + logger.info("Starting predict worker...") logger.info("Command: %s" % str(cmd)) + os.makedirs('logs', exist_ok=True) daisy.call( cmd, log_out='logs/predict_%s_%d_%d.out' % (linajea_config.general.setup, From 008e49f17bed6f7892e6722afc8cfdc1331b4eda Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Fri, 6 Aug 2021 09:38:23 -0400 Subject: [PATCH 121/263] Change info to debug in extract edges logging --- linajea/process_blockwise/extract_edges_blockwise.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/linajea/process_blockwise/extract_edges_blockwise.py b/linajea/process_blockwise/extract_edges_blockwise.py index 7587b7c..33b6442 100644 --- a/linajea/process_blockwise/extract_edges_blockwise.py +++ b/linajea/process_blockwise/extract_edges_blockwise.py @@ -86,7 +86,7 @@ def extract_edges_in_block( edge_move_threshold, block): - logger.info( + logger.debug( "Finding edges in %s, reading from %s", block.write_roi, block.read_roi) @@ -101,12 +101,12 @@ def extract_edges_in_block( graph = graph_provider[block.read_roi] if graph.number_of_nodes() == 0: - logger.info("No cells in roi %s. Skipping", block.read_roi) + logger.debug("No cells in roi %s. Skipping", block.read_roi) write_done(block, 'extract_edges', data.db_name, linajea_config.general.db_host) return 0 - logger.info( + logger.debug( "Read %d cells in %.3fs", graph.number_of_nodes(), time.time() - start) @@ -194,9 +194,9 @@ def extract_edges_in_block( distance=distance, prediction_distance=prediction_distance) - logger.info("Found %d edges", graph.number_of_edges()) + logger.debug("Found %d edges", graph.number_of_edges()) - logger.info( + logger.debug( "Extracted edges in %.3fs", time.time() - start) @@ -204,7 +204,7 @@ def extract_edges_in_block( graph.write_edges(block.write_roi) - logger.info( + logger.debug( "Wrote edges in %.3fs", time.time() - start) write_done(block, 'extract_edges', data.db_name, From 748494f46283eb20f204a15879b59a3db64168ce Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 1 Jul 2021 13:51:19 -0400 Subject: [PATCH 122/263] Add ctc visualization script --- linajea/visualization/ctc/write_ctc.py | 142 +++++++++++++++++++------ 1 file changed, 109 insertions(+), 33 deletions(-) diff --git a/linajea/visualization/ctc/write_ctc.py b/linajea/visualization/ctc/write_ctc.py index 804d8b2..3ecab5a 100644 --- a/linajea/visualization/ctc/write_ctc.py +++ b/linajea/visualization/ctc/write_ctc.py @@ -4,21 +4,39 @@ import numpy as np import raster_geometry as rg import tifffile +import mahotas +import skimage.measure logger = logging.getLogger(__name__) logging.basicConfig(level=20) + +def watershed(surface, markers, fg): + # compute watershed + ws = mahotas.cwatershed(1.0-surface, markers) + + # overlay fg and write + wsFG = ws * fg + logger.debug("watershed (foreground only): %s %s %f %f", + wsFG.shape, wsFG.dtype, wsFG.max(), + wsFG.min()) + wsFGUI = wsFG.astype(np.uint16) + + return wsFGUI, wsFG + + def write_ctc(graph, start_frame, end_frame, shape, out_dir, txt_fn, tif_fn, paint_sphere=False, - voxel_size=None, gt=False): + voxel_size=None, gt=False, surface=None, fg_threshold=0.5, + mask=None): os.makedirs(out_dir, exist_ok=True) logger.info("writing frames %s,%s (graph %s)", start_frame, end_frame, graph.get_frames()) # assert start_frame >= graph.get_frames()[0] if end_frame is None: - end_frame = graph.get_frames()[1] + end_frame = graph.get_frames()[1] + 1 # else: # assert end_frame <= graph.get_frames()[1] track_cntr = 1 @@ -31,7 +49,7 @@ def write_ctc(graph, start_frame, end_frame, shape, prev_cells = curr_cells cells_by_t_data = {} - for f in range(start_frame+1, end_frame+1): + for f in range(start_frame+1, end_frame): cells_by_t_data[f] = [] curr_cells = set(graph.cells_by_frame(f)) for c in prev_cells: @@ -92,7 +110,7 @@ def write_ctc(graph, start_frame, end_frame, shape, for cell, data in graph.nodes(data=True) if 't' in data and data['t'] == t ] - for t in range(start_frame, end_frame+1) + for t in range(start_frame, end_frame) } with open(os.path.join(out_dir, "parent_vectors.txt"), 'w') as of: for t, cs in cells_by_t_data.items(): @@ -108,18 +126,24 @@ def write_ctc(graph, start_frame, end_frame, shape, of.write("{} {} {} {}\n".format( t, min(v[1]), max(v[1]), v[0])) - if paint_sphere: spheres = {} - radii = {30:35, - 60:25, - 100:15, - 1000:11, + radii = {30: 35, + 60: 25, + 100: 15, + 1000: 11, } - radii = {30:71, - 60:51, - 100:31, - 1000:21, + radii = {30: 71, + 60: 51, + 100: 31, + 1000: 21, + } + radii = {15: 71, + 30: 61, + 60: 61, + 90: 51, + 120: 31, + 1000: 21, } for th, r in radii.items(): @@ -132,10 +156,13 @@ def write_ctc(graph, start_frame, end_frame, shape, sphere_shape[2]/2) sphere = rg.ellipsoid(sphere_shape, sphere_rad) spheres[th] = [sphere, zh, yh, xh] - # print(sphere) - # print(shape, sphere.shape) - for f in range(start_frame, end_frame+1): + for f in range(start_frame, end_frame): arr = np.zeros(shape[1:], dtype=np.uint16) + if surface is not None: + fg = (surface[f] > fg_threshold).astype(np.uint8) + if mask: + fg *= mask + for c, v in node_to_track.items(): if f != v[2]: continue @@ -143,7 +170,8 @@ def write_ctc(graph, start_frame, end_frame, shape, z = int(graph.nodes[c]['z']/voxel_size[1]) y = int(graph.nodes[c]['y']/voxel_size[2]) x = int(graph.nodes[c]['x']/voxel_size[3]) - print(v[0], f, t, z, y, x, c, v) + logger.debug("%s %s %s %s %s %s %s %s", + v[0], f, t, z, y, x, c, v) if paint_sphere: if isinstance(spheres, dict): for th in sorted(spheres.keys()): @@ -155,59 +183,107 @@ def write_ctc(graph, start_frame, end_frame, shape, (y-yh):(y+yh+1), (x-xh):(x+xh+1)] = sphere * v[0] except ValueError as e: - print(e) - print(z, zh, y, yh, x, xh, sphere.shape) - print(z-zh, z+zh+1, y-yh, y+yh+1, x-xh, x+xh+1, sphere.shape) + logger.debug("%s", e) + logger.debug("%s %s %s %s %s %s %s", + z, zh, y, yh, x, xh, sphere.shape) + logger.debug("%s %s %s %s %s %s %s", + z-zh, z+zh+1, y-yh, y+yh+1, x-xh, x+xh+1, + sphere.shape) sphereT = np.copy(sphere) if z-zh < 0: zz1 = 0 - sphereT = sphereT[(-(z-zh)):,...] - print(sphereT.shape) + sphereT = sphereT[(-(z-zh)):, ...] + logger.debug("%s", sphereT.shape) else: zz1 = z-zh if z+zh+1 >= arr.shape[0]: zz2 = arr.shape[0] zt = arr.shape[0] - (z+zh+1) - sphereT = sphereT[:zt,...] - print(sphereT.shape) + sphereT = sphereT[:zt, ...] + logger.debug("%s", sphereT.shape) else: zz2 = z+zh+1 if y-yh < 0: yy1 = 0 - sphereT = sphereT[:, (-(y - yh)) :, ...] - print(sphereT.shape) + sphereT = sphereT[:, (-(y - yh)):, ...] + logger.debug("%s", sphereT.shape) else: yy1 = y-yh if y+yh+1 >= arr.shape[1]: yy2 = arr.shape[1] yt = arr.shape[1] - (y+yh+1) - sphereT = sphereT[:,:yt,...] - print(sphereT.shape) + sphereT = sphereT[:, :yt, ...] + logger.debug("%s", sphereT.shape) else: yy2 = y+yh+1 if x-xh < 0: xx1 = 0 - sphereT = sphereT[...,(-(x-xh)):] - print(sphereT.shape) + sphereT = sphereT[..., (-(x-xh)):] + logger.debug("%s", sphereT.shape) else: xx1 = x-xh if x+xh+1 >= arr.shape[2]: xx2 = arr.shape[2] xt = arr.shape[2] - (x+xh+1) - sphereT = sphereT[...,:xt] - print(sphereT.shape) + sphereT = sphereT[..., :xt] + logger.debug("%s", sphereT.shape) else: xx2 = x+xh+1 - print(zz1, zz2, yy1, yy2, xx1, xx2) + logger.debug("%s %s %s %s %s %s", + zz1, zz2, yy1, yy2, xx1, xx2) arr[zz1:zz2, yy1:yy2, xx1:xx2] = sphereT * v[0] # raise e else: arr[z, y, x] = v[0] + if surface is not None: + radii = {10000: 12, + } + for th in sorted(radii.keys()): + if f < th: + d = radii[th] + break + logger.debug("%s %s %s %s", + f, surface[f].shape, arr.shape, fg.shape) + arr1, arr2 = watershed(surface[f], arr, fg) + arr_tmp = np.zeros_like(arr) + tmp1 = np.argwhere(arr != 0) + for n in tmp1: + u = arr1[tuple(n)] + tmp = (arr1 == u).astype(np.uint32) + tmp = skimage.measure.label(tmp) + val = tmp[tuple(n)] + tmp = tmp == val + + # for v in np.argwhere(tmp != 0): + # if np.linalg.norm(n-v) < d: + # arr_tmp[tuple(v)] = u + + vs = np.argwhere(tmp != 0) + + vss = np.copy(vs) + vss[:, 0] *= 5 + n[0] *= 5 + tmp2 = np.argwhere(np.linalg.norm(n-vss, axis=1) < d) + assert len(tmp2) > 0,\ + "no pixel found {} {} {} {}".format(f, d, n, val) + for v in tmp2: + arr_tmp[tuple(vs[v][0])] = u + arr = arr_tmp + tifffile.imwrite(os.path.join( out_dir, tif_fn.format(f)), arr, compress=3) + # tifffile.imwrite(os.path.join( + # out_dir, "ws" + tif_fn.format(f)), arr2, + # compress=3) + # tifffile.imwrite(os.path.join( + # out_dir, "surf" + tif_fn.format(f)), surface[f], + # compress=3) + # tifffile.imwrite(os.path.join( + # out_dir, "fg" + tif_fn.format(f)), fg, + # compress=3) From 25f3ed0953dd1800b5986f7bb0b344f68f3e3e04 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 19 Aug 2021 09:52:30 -0400 Subject: [PATCH 123/263] Change solver preference to any --- linajea/config/solve.py | 1 + linajea/tracking/non_minimal_solver.py | 7 ++++--- linajea/tracking/non_minimal_track.py | 1 + linajea/tracking/solver.py | 7 ++++--- linajea/tracking/track.py | 1 + 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index cdd1ed0..daad0ed 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -223,6 +223,7 @@ class SolveConfig: write_struct_svm = attr.ib(type=bool, default=False) check_node_close_to_roi = attr.ib(type=bool, default=True) add_node_density_constraints = attr.ib(type=bool, default=False) + timeout = attr.ib(type=int, default=120) def __attrs_post_init__(self): assert self.parameters is not None or \ diff --git a/linajea/tracking/non_minimal_solver.py b/linajea/tracking/non_minimal_solver.py index a9cfd73..83b178b 100644 --- a/linajea/tracking/non_minimal_solver.py +++ b/linajea/tracking/non_minimal_solver.py @@ -14,7 +14,7 @@ class NMSolver(object): relationships ''' def __init__(self, track_graph, parameters, selected_key, frames=None, - check_node_close_to_roi=True, + check_node_close_to_roi=True, timeout=120, add_node_density_constraints=False): # frames: [start_frame, end_frame] where start_frame is inclusive # and end_frame is exclusive. Defaults to track_graph.begin, @@ -27,6 +27,7 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, self.selected_key = selected_key self.start_frame = frames[0] if frames else self.graph.begin self.end_frame = frames[1] if frames else self.graph.end + self.timeout = timeout self.node_selected = {} self.edge_selected = {} @@ -72,9 +73,9 @@ def _create_solver(self): self.solver = pylp.LinearSolver( self.num_vars, pylp.VariableType.Binary, - preference=pylp.Preference.Gurobi) + preference=pylp.Preference.Any) self.solver.set_num_threads(1) - self.solver.set_timeout(120) + self.solver.set_timeout(self.timeout) def solve(self): solution, message = self.solver.solve() diff --git a/linajea/tracking/non_minimal_track.py b/linajea/tracking/non_minimal_track.py index 9443fa2..b2f4a41 100644 --- a/linajea/tracking/non_minimal_track.py +++ b/linajea/tracking/non_minimal_track.py @@ -67,6 +67,7 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): solver = NMSolver( track_graph, parameter, key, frames=frames, check_node_close_to_roi=config.solve.check_node_close_to_roi, + timeout=config.solve.timeout, add_node_density_constraints=config.solve.add_node_density_constraints) else: solver.update_objective(parameter, key) diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index c036752..b34119e 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -14,7 +14,7 @@ class Solver(object): ''' def __init__(self, track_graph, parameters, selected_key, frames=None, write_struct_svm=False, block_id=None, - check_node_close_to_roi=True, + check_node_close_to_roi=True, timeout=120, add_node_density_constraints=False): # frames: [start_frame, end_frame] where start_frame is inclusive # and end_frame is exclusive. Defaults to track_graph.begin, @@ -27,6 +27,7 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, self.graph = track_graph self.start_frame = frames[0] if frames else self.graph.begin self.end_frame = frames[1] if frames else self.graph.end + self.timeout = timeout self.node_selected = {} self.edge_selected = {} @@ -67,9 +68,9 @@ def _create_solver(self): self.solver = pylp.LinearSolver( self.num_vars, pylp.VariableType.Binary, - preference=pylp.Preference.Gurobi) + preference=pylp.Preference.Any) self.solver.set_num_threads(1) - self.solver.set_timeout(120) + self.solver.set_timeout(self.timeout) def solve(self): solution, message = self.solver.solve() diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 1ad2e1b..8a2f223 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -95,6 +95,7 @@ def track(graph, config, selected_key, frame_key='t', frames=None, write_struct_svm=config.solve.write_struct_svm, block_id=block_id, check_node_close_to_roi=config.solve.check_node_close_to_roi, + timeout=config.solve.timeout, add_node_density_constraints=config.solve.add_node_density_constraints) else: solver.update_objective(parameter, key) From 2ae885b75f6a9025916e13c8fafd4653ac54033a Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 19 Aug 2021 09:53:52 -0400 Subject: [PATCH 124/263] Add logging for progress on ctc visualization --- linajea/visualization/ctc/write_ctc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linajea/visualization/ctc/write_ctc.py b/linajea/visualization/ctc/write_ctc.py index 3ecab5a..f8d6cf1 100644 --- a/linajea/visualization/ctc/write_ctc.py +++ b/linajea/visualization/ctc/write_ctc.py @@ -157,6 +157,7 @@ def write_ctc(graph, start_frame, end_frame, shape, sphere = rg.ellipsoid(sphere_shape, sphere_rad) spheres[th] = [sphere, zh, yh, xh] for f in range(start_frame, end_frame): + logger.info("Processing frame %d" % f) arr = np.zeros(shape[1:], dtype=np.uint16) if surface is not None: fg = (surface[f] > fg_threshold).astype(np.uint8) @@ -274,7 +275,7 @@ def write_ctc(graph, start_frame, end_frame, shape, for v in tmp2: arr_tmp[tuple(vs[v][0])] = u arr = arr_tmp - + logger.info("Writing tiff tile for frame %d" % f) tifffile.imwrite(os.path.join( out_dir, tif_fn.format(f)), arr, compress=3) From ae19e748f86ec0e880e3fd28350304e16a0caf37 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 19 Aug 2021 09:54:15 -0400 Subject: [PATCH 125/263] Add ctc visualization module to setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index f73c78f..4fe88f3 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ 'linajea.process_blockwise', 'linajea.visualization', 'linajea.visualization.mamut', + 'linajea.visualization.ctc', ], python_requires="<3.8" ) From 736849afb3e144bce76cde359e22315e72efc067 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Thu, 19 Aug 2021 09:55:28 -0400 Subject: [PATCH 126/263] Add ctc dependencies to Singularity container --- singularity/Singularity | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/singularity/Singularity b/singularity/Singularity index 3abdc8e..352bc4a 100644 --- a/singularity/Singularity +++ b/singularity/Singularity @@ -21,10 +21,10 @@ mkdir -p ${SINGULARITY_ROOTFS}/src/linajea maintainer funkej@janelia.hhmi.org %post - PATH="/miniconda/bin:$PATH" pip install zarr +pip install imagecodecs # install gunpowder @@ -97,6 +97,10 @@ git checkout ${FUNLIB_RUN_REVISION} pip install -r requirements.txt PYTHONPATH=${FUNLIB_RUN_ROOT}:$PYTHONPATH +# install CTC dependencies +pip install raster_geometry +pip install mahotas +pip install tifffile %environment export DAISY_ROOT=/src/daisy From 332a54a215e5aad7e6262653ba3268f2a06fc4e6 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 12:51:05 +0100 Subject: [PATCH 127/263] add optional string tag to result db --- linajea/check_or_create_db.py | 4 +++- linajea/config/general.py | 1 + linajea/construct_zarr_filename.py | 5 +++-- linajea/get_next_inference_data.py | 3 ++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/linajea/check_or_create_db.py b/linajea/check_or_create_db.py index 0ff6e8b..70e9864 100644 --- a/linajea/check_or_create_db.py +++ b/linajea/check_or_create_db.py @@ -6,7 +6,7 @@ def checkOrCreateDB(db_host, setup_dir, sample, checkpoint, cell_score_threshold, prefix="linajea_", - create_if_not_found=True): + tag=None, create_if_not_found=True): db_host = db_host info = {} @@ -14,6 +14,8 @@ def checkOrCreateDB(db_host, setup_dir, sample, checkpoint, info["iteration"] = checkpoint info["cell_score_threshold"] = cell_score_threshold info["sample"] = os.path.basename(sample) + if tag is not None: + info["tag"] = tag return checkOrCreateDBMeta(db_host, info, prefix=prefix, create_if_not_found=create_if_not_found) diff --git a/linajea/config/general.py b/linajea/config/general.py index 66e07c1..469c7fa 100644 --- a/linajea/config/general.py +++ b/linajea/config/general.py @@ -13,6 +13,7 @@ class GeneralConfig: db_name = attr.ib(type=str, default=None) singularity_image = attr.ib(type=str, default=None) sparse = attr.ib(type=bool, default=True) + tag = attr.ib(type=str, default=None) seed = attr.ib(type=int) logging = attr.ib(type=int) diff --git a/linajea/construct_zarr_filename.py b/linajea/construct_zarr_filename.py index e423851..e4cdfb1 100644 --- a/linajea/construct_zarr_filename.py +++ b/linajea/construct_zarr_filename.py @@ -6,5 +6,6 @@ def construct_zarr_filename(config, sample, checkpoint): config.predict.output_zarr_prefix, config.general.setup, os.path.basename(sample) + - 'predictions' + - str(checkpoint) + '.zarr') + 'predictions' + (config.general.tag if config.general.tag is not None else "") + + str(checkpoint) + "_" + + str(config.inference.cell_score_threshold).replace(".", "_") + '.zarr') diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py index 4a9875e..1585686 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/get_next_inference_data.py @@ -54,7 +54,8 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): config.general.setup_dir, sample.datafile.filename, checkpoint, - inference.cell_score_threshold) + inference.cell_score_threshold, + tag=config.general.tag) inference_data['data_source'] = sample config.inference = InferenceDataTrackingConfig(**inference_data) # type: ignore if is_solve: From 2cd1060a72e57e0070d66bf7beec5622d215af15 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 12:53:04 +0100 Subject: [PATCH 128/263] config: update augment/add new ones --- linajea/config/augment.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/linajea/config/augment.py b/linajea/config/augment.py index 55e5452..0ce3d5f 100644 --- a/linajea/config/augment.py +++ b/linajea/config/augment.py @@ -10,6 +10,7 @@ class AugmentElasticConfig: jitter_sigma = attr.ib(type=List[int]) rotation_min = attr.ib(type=int) rotation_max = attr.ib(type=int) + rotation_3d = attr.ib(type=bool, default=False) subsample = attr.ib(type=int, default=1) use_fast_points_transform = attr.ib(type=bool, default=False) @@ -37,6 +38,9 @@ class AugmentSimpleConfig: class AugmentNoiseGaussianConfig: var = attr.ib(type=float) +@attr.s(kw_only=True) +class AugmentNoiseSpeckleConfig: + var = attr.ib(type=float) @attr.s(kw_only=True) class AugmentNoiseSaltPepperConfig: @@ -47,6 +51,18 @@ class AugmentNoiseSaltPepperConfig: class AugmentJitterConfig: jitter = attr.ib(type=List[int]) +@attr.s(kw_only=True) +class AugmentZoomConfig: + factor_min = attr.ib(type=float) + factor_max = attr.ib(type=float) + spatial_dims = attr.ib(type=int) + +@attr.s(kw_only=True) +class AugmentHistogramConfig: + range_low = attr.ib(type=float) + range_high = attr.ib(type=float) + after_int_aug = attr.ib(type=bool, default=True) + @attr.s(kw_only=True) class AugmentConfig: @@ -60,24 +76,31 @@ class AugmentConfig: default=None) noise_gaussian = attr.ib(converter=ensure_cls(AugmentNoiseGaussianConfig), default=None) + noise_speckle = attr.ib(converter=ensure_cls(AugmentNoiseSpeckleConfig), + default=None) noise_saltpepper = attr.ib(converter=ensure_cls(AugmentNoiseSaltPepperConfig), default=None) + zoom = attr.ib(converter=ensure_cls(AugmentZoomConfig), + default=None) + histogram = attr.ib(converter=ensure_cls(AugmentHistogramConfig), + default=None) @attr.s(kw_only=True) class AugmentTrackingConfig(AugmentConfig): reject_empty_prob = attr.ib(type=float) # (default=1.0?) - norm_bounds = attr.ib(type=List[int]) + norm_bounds = attr.ib(type=List[int], default=None) divisions = attr.ib(type=bool) # float for percentage? normalization = attr.ib(type=str, default=None) perc_min = attr.ib(type=str, default=None) perc_max = attr.ib(type=str, default=None) + point_balance_radius = attr.ib(type=int, default=1) @attr.s(kw_only=True) class AugmentCellCycleConfig(AugmentConfig): - min_key = attr.ib(type=str) - max_key = attr.ib(type=str) + min_key = attr.ib(type=str, default=None) + max_key = attr.ib(type=str, default=None) norm_min = attr.ib(type=int, default=None) norm_max = attr.ib(type=int, default=None) jitter = attr.ib(converter=ensure_cls(AugmentJitterConfig), From d4b296b73495a3fa719fe918c7d24a50898a1582 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 12:54:41 +0100 Subject: [PATCH 129/263] config: add framework specific optimizer config --- linajea/config/cell_cycle_config.py | 25 +++++++++++-- linajea/config/optimizer.py | 56 ++++++++++++++++++++++++----- linajea/config/tracking_config.py | 17 +++++++-- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/linajea/config/cell_cycle_config.py b/linajea/config/cell_cycle_config.py index 6e81d9e..0d3bd0a 100644 --- a/linajea/config/cell_cycle_config.py +++ b/linajea/config/cell_cycle_config.py @@ -6,7 +6,10 @@ VGGConfig) from .evaluate import EvaluateCellCycleConfig from .general import GeneralConfig -from .optimizer import OptimizerConfig +from .optimizer import (OptimizerTF1Config, + OptimizerTF2Config, + OptimizerTorchConfig) + from .predict import PredictCellCycleConfig from .train_test_validate_data import (TestDataCellCycleConfig, TrainDataCellCycleConfig, @@ -34,7 +37,9 @@ class CellCycleConfig: path = attr.ib(type=str) general = attr.ib(converter=ensure_cls(GeneralConfig)) model = attr.ib(converter=model_converter()) - optimizer = attr.ib(converter=ensure_cls(OptimizerConfig)) + optimizerTF1 = attr.ib(converter=ensure_cls(OptimizerTF1Config), default=None) + optimizerTF2 = attr.ib(converter=ensure_cls(OptimizerTF2Config), default=None) + optimizerTorch = attr.ib(converter=ensure_cls(OptimizerTorchConfig), default=None) train = attr.ib(converter=ensure_cls(TrainCellCycleConfig)) train_data = attr.ib(converter=ensure_cls(TrainDataCellCycleConfig)) test_data = attr.ib(converter=ensure_cls(TestDataCellCycleConfig)) @@ -45,4 +50,20 @@ class CellCycleConfig: @classmethod def from_file(cls, path): config_dict = load_config(path) + # if 'path' in config_dict: + # assert path == config_dict['path'], "{} {}".format(path, config_dict['path']) + # del config_dict['path'] + try: + del config_dict['path'] + except: + pass return cls(path=path, **config_dict) # type: ignore + + def __attrs_post_init__(self): + assert (int(bool(self.optimizerTF1)) + + int(bool(self.optimizerTF2)) + + int(bool(self.optimizerTorch))) == 1, \ + "please specify exactly one optimizer config (tf1, tf2, torch)" + + if self.predict.use_swa is None: + self.predict.use_swa = self.train.use_swa diff --git a/linajea/config/optimizer.py b/linajea/config/optimizer.py index 9ac4afd..20854de 100644 --- a/linajea/config/optimizer.py +++ b/linajea/config/optimizer.py @@ -5,26 +5,66 @@ @attr.s(kw_only=True) -class OptimizerArgsConfig: - learning_rate = attr.ib(type=float) +class OptimizerTF1ArgsConfig: + weight_decay = attr.ib(type=float, default=None) + learning_rate = attr.ib(type=float, default=None) momentum = attr.ib(type=float, default=None) - @attr.s(kw_only=True) -class OptimizerKwargsConfig: +class OptimizerTF1KwargsConfig: + learning_rate = attr.ib(type=float, default=None) beta1 = attr.ib(type=float, default=None) beta2 = attr.ib(type=float, default=None) epsilon = attr.ib(type=float, default=None) - + # to be extended for other (not Adam) optimizers @attr.s(kw_only=True) -class OptimizerConfig: +class OptimizerTF1Config: optimizer = attr.ib(type=str, default="AdamOptimizer") - args = attr.ib(converter=ensure_cls(OptimizerArgsConfig)) - kwargs = attr.ib(converter=ensure_cls(OptimizerKwargsConfig)) + lr_schedule = attr.ib(type=str, default=None) + args = attr.ib(converter=ensure_cls(OptimizerTF1ArgsConfig)) + kwargs = attr.ib(converter=ensure_cls(OptimizerTF1KwargsConfig)) def get_args(self): return [v for v in attr.astuple(self.args) if v is not None] def get_kwargs(self): return {a:v for a,v in attr.asdict(self.kwargs).items() if v is not None} + + +@attr.s(kw_only=True) +class OptimizerTF2KwargsConfig: + beta_1 = attr.ib(type=float, default=None) + beta_2 = attr.ib(type=float, default=None) + epsilon = attr.ib(type=float, default=None) + learning_rate = attr.ib(type=float, default=None) + momentum = attr.ib(type=float, default=None) + # to be extended for other (not Adam) optimizers + +@attr.s(kw_only=True) +class OptimizerTF2Config: + optimizer = attr.ib(type=str, default="Adam") + kwargs = attr.ib(converter=ensure_cls(OptimizerTF2KwargsConfig)) + + def get_kwargs(self): + return {a:v for a,v in attr.asdict(self.kwargs).items() if v is not None} + + +@attr.s(kw_only=True) +class OptimizerTorchKwargsConfig: + betas = attr.ib(type=List[float], default=None) + eps = attr.ib(type=float, default=None) + lr = attr.ib(type=float, default=None) + amsgrad = attr.ib(type=bool, default=None) + momentum = attr.ib(type=float, default=None) + nesterov = attr.ib(type=bool, default=None) + weight_decay = attr.ib(type=float, default=None) + # to be extended for other (not Adam) optimizers + +@attr.s(kw_only=True) +class OptimizerTorchConfig: + optimizer = attr.ib(type=str, default="Adam") + kwargs = attr.ib(converter=ensure_cls(OptimizerTorchKwargsConfig)) + + def get_kwargs(self): + return {a:v for a,v in attr.asdict(self.kwargs).items() if v is not None} diff --git a/linajea/config/tracking_config.py b/linajea/config/tracking_config.py index b83dc3f..393ef1b 100644 --- a/linajea/config/tracking_config.py +++ b/linajea/config/tracking_config.py @@ -4,7 +4,9 @@ from .evaluate import EvaluateTrackingConfig from .extract import ExtractConfig from .general import GeneralConfig -from .optimizer import OptimizerConfig +from .optimizer import (OptimizerTF1Config, + OptimizerTF2Config, + OptimizerTorchConfig) from .predict import PredictTrackingConfig from .solve import SolveConfig # from .test import TestTrackingConfig @@ -22,7 +24,9 @@ class TrackingConfig: path = attr.ib(type=str) general = attr.ib(converter=ensure_cls(GeneralConfig)) model = attr.ib(converter=ensure_cls(UnetConfig)) - optimizer = attr.ib(converter=ensure_cls(OptimizerConfig)) + optimizerTF1 = attr.ib(converter=ensure_cls(OptimizerTF1Config), default=None) + optimizerTF2 = attr.ib(converter=ensure_cls(OptimizerTF2Config), default=None) + optimizerTorch = attr.ib(converter=ensure_cls(OptimizerTorchConfig), default=None) train = attr.ib(converter=ensure_cls(TrainTrackingConfig)) train_data = attr.ib(converter=ensure_cls(TrainDataTrackingConfig)) test_data = attr.ib(converter=ensure_cls(TestDataTrackingConfig)) @@ -38,3 +42,12 @@ def from_file(cls, path): config_dict = load_config(path) config_dict["path"] = path return cls(**config_dict) # type: ignore + + def __attrs_post_init__(self): + assert (int(bool(self.optimizerTF1)) + + int(bool(self.optimizerTF2)) + + int(bool(self.optimizerTorch))) == 1, \ + "please specify exactly one optimizer config (tf1, tf2, torch)" + + if self.predict.use_swa is None: + self.predict.use_swa = self.train.use_swa From 56f0a4d7c6fe935e700036e36bc403e4f7910b17 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 14:37:10 +0100 Subject: [PATCH 130/263] adapt script paths based on host --- linajea/config/__init__.py | 2 ++ linajea/config/utils.py | 35 ++++++++++++++++++++++++++++++ linajea/get_next_inference_data.py | 7 +++--- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index 673833a..4e476ea 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -26,3 +26,5 @@ # ValidateCellCycleConfig) from .train_test_validate_data import (InferenceDataTrackingConfig, InferenceDataCellCycleConfig) + +from .utils import maybe_fix_config_paths_to_machine_and_load diff --git a/linajea/config/utils.py b/linajea/config/utils.py index 62144e8..ef7b611 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -68,3 +68,38 @@ def _check_shape(self, attribute, value): _list_int_list_validator = attr.validators.deep_iterable( member_validator=_int_list_validator, iterable_validator=attr.validators.instance_of(list)) + +def maybe_fix_config_paths_to_machine_and_load(config): + config_dict = toml.load(config) + config_dict["path"] = config + + if os.path.isfile(os.path.join(os.environ['HOME'], "linajea_paths.toml")): + paths = load_config(os.path.join(os.environ['HOME'], "linajea_paths.toml")) + # if paths["DATA"] == "TMPDIR": + # paths["DATA"] = os.environ['TMPDIR'] + config_dict["general"]["setup_dir"] = config_dict["general"]["setup_dir"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + config_dict["model"]["path_to_script"] = config_dict["model"]["path_to_script"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + config_dict["train"]["path_to_script"] = config_dict["train"]["path_to_script"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + config_dict["predict"]["path_to_script"] = config_dict["predict"]["path_to_script"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + config_dict["predict"]["path_to_script_db_from_zarr"] = config_dict["predict"]["path_to_script_db_from_zarr"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + config_dict["predict"]["output_zarr_prefix"] = config_dict["predict"]["output_zarr_prefix"].replace( + "/nrs/funke/hirschp/linajea_experiments", + paths["DATA"]) + for dt in [config_dict["train_data"]["data_sources"], + config_dict["test_data"]["data_sources"], + config_dict["validate_data"]["data_sources"]]: + for ds in dt: + ds["datafile"]["filename"] = ds["datafile"]["filename"].replace( + "/nrs/funke/hirschp", + paths["DATA"]) + return config_dict diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py index 1585686..f7b4185 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/get_next_inference_data.py @@ -11,14 +11,15 @@ from linajea.config import (InferenceDataTrackingConfig, SolveParametersMinimalConfig, SolveParametersNonMinimalConfig, - TrackingConfig) + TrackingConfig, + maybe_fix_config_paths_to_machine_and_load) logger = logging.getLogger(__name__) -# TODO: better name maybe? def getNextInferenceData(args, is_solve=False, is_evaluate=False): - config = TrackingConfig.from_file(args.config) + config = maybe_fix_config_paths_to_machine_and_load(args.config) + config = TrackingConfig(**config) if args.validation: inference = deepcopy(config.validate_data) From b592d399dd3bf513cd8f984fb9f55630d8aa458b Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 14:47:49 +0100 Subject: [PATCH 131/263] add code to handle polar bodies --- linajea/config/cnn_config.py | 2 + linajea/config/data.py | 15 ++++++- linajea/config/evaluate.py | 3 +- linajea/evaluation/evaluate_setup.py | 66 ++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/linajea/config/cnn_config.py b/linajea/config/cnn_config.py index 0c01579..8fddb7d 100644 --- a/linajea/config/cnn_config.py +++ b/linajea/config/cnn_config.py @@ -29,11 +29,13 @@ class CNNConfig: num_classes = attr.ib(type=int, default=3) classes = attr.ib(type=List[str]) class_ids = attr.ib(type=List[int]) + class_sampling_weights = attr.ib(type=List[int], default=[6, 2, 2, 2]) network_type = attr.ib( type=str, validator=attr.validators.in_(["vgg", "resnet", "efficientnet"])) make_isotropic = attr.ib(type=int, default=False) regularizer_weight = attr.ib(type=float, default=None) + with_polar = attr.ib(type=int, default=False) @attr.s(kw_only=True) class VGGConfig(CNNConfig): diff --git a/linajea/config/data.py b/linajea/config/data.py index 7b7d3a9..afb7ff5 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -35,8 +35,18 @@ def __attrs_post_init__(self): self.file_roi = DataROIConfig(offset=attributes.offset, shape=attributes.shape) # type: ignore else: - data_config = load_config(os.path.join(self.filename, - "data_config.toml")) + filename = self.filename + is_polar = "polar" in filename + if is_polar: + filename = filename.replace("_polar", "") + print(filename) + if os.path.isdir(filename): + data_config = load_config(os.path.join(filename, + "data_config.toml")) + else: + data_config = load_config( + os.path.join(os.path.dirname(filename), + "data_config.toml")) self.file_voxel_size = data_config['general']['resolution'] self.file_roi = DataROIConfig( offset=data_config['general']['offset'], @@ -60,5 +70,6 @@ class DataSourceConfig: datafile = attr.ib(converter=ensure_cls(DataFileConfig)) db_name = attr.ib(type=str, default=None) gt_db_name = attr.ib(type=str, default=None) + gt_db_name_polar = attr.ib(type=str, default=None) voxel_size = attr.ib(type=List[int], default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 2435de9..e3fb571 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -13,7 +13,8 @@ class EvaluateParametersConfig: roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) validation_score = attr.ib(type=bool, default=False) window_size = attr.ib(type=int, default=50) - + filter_polar_bodies = attr.ib(type=bool, default=None) + filter_polar_bodies_key = attr.ib(type=str, default=None) # deprecated frames = attr.ib(type=List[int], default=None) # deprecated diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index d93eadf..dfc4b03 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -3,6 +3,8 @@ import sys import time +import networkx as nx + import daisy import linajea.tracking @@ -56,6 +58,56 @@ def evaluate_setup(linajea_config): logger.warn("No selected edges for parameters_id %d. Skipping" % parameters_id) return + + if linajea_config.evaluate.parameters.filter_polar_bodies or \ + linajea_config.evaluate.parameters.filter_polar_bodies_key: + logger.debug("%s %s", + linajea_config.evaluate.parameters.filter_polar_bodies, + linajea_config.evaluate.parameters.filter_polar_bodies_key) + if not linajea_config.evaluate.parameters.filter_polar_bodies and \ + linajea_config.evaluate.parameters.filter_polar_bodies_key is not None: + pb_key = linajea_config.evaluate.parameters.filter_polar_bodies_key + else: + pb_key = parameters.cell_cycle_key + "polar" + + # temp. remove edges from mother to daughter cells to split into chains + tmp_subgraph = edges_db.get_selected_graph(evaluate_roi) + for node in list(tmp_subgraph.nodes()): + if tmp_subgraph.degree(node) > 2: + es = list(tmp_subgraph.predecessors(node)) + tmp_subgraph.remove_edge(es[0], node) + tmp_subgraph.remove_edge(es[1], node) + rec_graph = linajea.tracking.TrackGraph( + tmp_subgraph, frame_key='t', roi=tmp_subgraph.roi) + + # for each chain + for track in rec_graph.get_tracks(): + cnt_nodes = 0 + cnt_polar = 0 + cnt_polar_uninterrupted = [[]] + nodes = [] + for node_id, node in track.nodes(data=True): + nodes.append((node['t'], node_id, node)) + + # check if > 50% are polar bodies + nodes = sorted(nodes) + for _, node_id, node in nodes: + cnt_nodes += 1 + try: + if node[pb_key] > 0.5: + cnt_polar += 1 + cnt_polar_uninterrupted[-1].append(node_id) + else: + cnt_polar_uninterrupted.append([]) + except KeyError: + pass + + # then remove + if cnt_polar/cnt_nodes > 0.5: + subgraph.remove_nodes_from(track.nodes()) + logger.info("removing %s potential polar nodes", + len(track.nodes())) + track_graph = linajea.tracking.TrackGraph( subgraph, frame_key='t', roi=subgraph.roi) @@ -70,6 +122,20 @@ def evaluate_setup(linajea_config): % (gt_subgraph.number_of_nodes(), gt_subgraph.number_of_edges(), time.time() - start_time)) + + if linajea_config.inference.data_source.gt_db_name_polar is not None and \ + not linajea_config.evaluate.parameters.filter_polar_bodies and \ + not linajea_config.evaluate.parameters.filter_polar_bodies_key: + logger.info("polar bodies are not filtered, adding polar body GT..") + gt_db_polar = linajea.CandidateDatabase( + linajea_config.inference.data_source.gt_db_name_polar, db_host) + gt_polar_subgraph = gt_db_polar[evaluate_roi] + gt_mx_id = max(gt_subgraph.nodes()) + 1 + mapping = {n: n+gt_mx_id for n in gt_polar_subgraph.nodes()} + gt_polar_subgraph = nx.relabel_nodes(gt_polar_subgraph, mapping, + copy=False) + gt_subgraph.update(gt_polar_subgraph) + gt_track_graph = linajea.tracking.TrackGraph( gt_subgraph, frame_key='t', roi=gt_subgraph.roi) From fa0ef37dfd37a6ab2f5412769b3c70de6e49984a Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 14:53:59 +0100 Subject: [PATCH 132/263] config: add SWA params --- linajea/config/predict.py | 1 + linajea/config/train.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/linajea/config/predict.py b/linajea/config/predict.py index 960f7a4..461d770 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -7,6 +7,7 @@ class PredictConfig: job = attr.ib(converter=ensure_cls(JobConfig)) path_to_script = attr.ib(type=str) + use_swa = attr.ib(type=bool, default=None) @attr.s(kw_only=True) class PredictTrackingConfig(PredictConfig): diff --git a/linajea/config/train.py b/linajea/config/train.py index 0dfa8ec..be2b7ce 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -28,7 +28,16 @@ class TrainConfig: profiling_stride = attr.ib(type=int) use_tf_data = attr.ib(type=bool, default=False) use_auto_mixed_precision = attr.ib(type=bool, default=False) - val_log_step = attr.ib(type=int) + use_swa = attr.ib(type=bool, default=False) + swa_every_it = attr.ib(type=bool, default=False) + swa_start_it = attr.ib(type=int, default=None) + swa_freq_it = attr.ib(type=int, default=None) + val_log_step = attr.ib(type=int, default=None) + + def __attrs_post_init__(self): + if self.use_swa: + assert self.swa_start_it is not None and self.swa_freq_it is not None, \ + "if swa is used, please set start and freq it" @attr.s(kw_only=True) From f10cef49e44835366572a9e4b74b3ed007213e2d Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 14:54:24 +0100 Subject: [PATCH 133/263] config: add grad norm param --- linajea/config/train.py | 1 + 1 file changed, 1 insertion(+) diff --git a/linajea/config/train.py b/linajea/config/train.py index be2b7ce..5de1ce5 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -32,6 +32,7 @@ class TrainConfig: swa_every_it = attr.ib(type=bool, default=False) swa_start_it = attr.ib(type=int, default=None) swa_freq_it = attr.ib(type=int, default=None) + use_grad_norm = attr.ib(type=bool, default=False) val_log_step = attr.ib(type=int, default=None) def __attrs_post_init__(self): From da5da759a9c94f4f3f0063f2439af9e3bbb92a14 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 14:57:25 +0100 Subject: [PATCH 134/263] config: add focal loss flag --- linajea/config/cnn_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/linajea/config/cnn_config.py b/linajea/config/cnn_config.py index 8fddb7d..6966c17 100644 --- a/linajea/config/cnn_config.py +++ b/linajea/config/cnn_config.py @@ -36,6 +36,7 @@ class CNNConfig: make_isotropic = attr.ib(type=int, default=False) regularizer_weight = attr.ib(type=float, default=None) with_polar = attr.ib(type=int, default=False) + focal_loss = attr.ib(type=bool, default=False) @attr.s(kw_only=True) class VGGConfig(CNNConfig): From 714db0bf783bdf5f19578ef2706e53e06e145c7b Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:03:09 +0100 Subject: [PATCH 135/263] config: add flags to control classifier eval/predict --- linajea/config/evaluate.py | 1 + linajea/config/train_test_validate_data.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index e3fb571..3d83631 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -74,4 +74,5 @@ class EvaluateCellCycleConfig(EvaluateConfig): prob_threshold = attr.ib(type=float) dry_run = attr.ib(type=bool) find_fn = attr.ib(type=bool) + force_eval = attr.ib(type=bool, default=False) parameters = attr.ib(converter=ensure_cls(EvaluateParametersCellCycleConfig)) diff --git a/linajea/config/train_test_validate_data.py b/linajea/config/train_test_validate_data.py index 236c0a0..3ef6983 100644 --- a/linajea/config/train_test_validate_data.py +++ b/linajea/config/train_test_validate_data.py @@ -93,6 +93,8 @@ class ValidateDataTrackingConfig(DataConfig): class DataCellCycleConfig(DataConfig): use_database = attr.ib(type=bool) db_meta_info = attr.ib(converter=ensure_cls(DataDBMetaConfig), default=None) + skip_predict = attr.ib(type=bool, default=False) + force_predict = attr.ib(type=bool, default=False) @attr.s(kw_only=True) @@ -104,14 +106,12 @@ class TrainDataCellCycleConfig(DataCellCycleConfig): class TestDataCellCycleConfig(DataCellCycleConfig): checkpoint = attr.ib(type=int) prob_threshold = attr.ib(type=float, default=None) - skip_predict = attr.ib(type=bool) @attr.s(kw_only=True) class ValidateDataCellCycleConfig(DataCellCycleConfig): checkpoints = attr.ib(type=List[int]) prob_thresholds = attr.ib(type=List[float], default=None) - skip_predict = attr.ib(type=bool) @attr.s(kw_only=True) @@ -121,3 +121,4 @@ class InferenceDataCellCycleConfig(): prob_threshold = attr.ib(type=float) use_database = attr.ib(type=bool) db_meta_info = attr.ib(converter=ensure_cls(DataDBMetaConfig), default=None) + force_predict = attr.ib(type=bool, default=False) From 4ce04d134193966557e6c753f29203e5a4ab3cd3 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:04:11 +0100 Subject: [PATCH 136/263] config: update setup_dir entry --- linajea/config/general.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/linajea/config/general.py b/linajea/config/general.py index 469c7fa..29881e9 100644 --- a/linajea/config/general.py +++ b/linajea/config/general.py @@ -7,7 +7,7 @@ class GeneralConfig: # set via post_init hook # setup = attr.ib(type=str) - setup_dir = attr.ib(type=str) + setup_dir = attr.ib(type=str, default=None) db_host = attr.ib(type=str) # sample = attr.ib(type=str) db_name = attr.ib(type=str, default=None) @@ -18,4 +18,5 @@ class GeneralConfig: logging = attr.ib(type=int) def __attrs_post_init__(self): - self.setup = os.path.basename(self.setup_dir) + if self.setup_dir is not None: + self.setup = os.path.basename(self.setup_dir) From 2d14c1ed1fe45ef70dea8a2946ae6297504e754e Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:06:01 +0100 Subject: [PATCH 137/263] add flag to prevent db access during prediction (e.g. only write zarr without checks --- linajea/config/predict.py | 3 +++ linajea/get_next_inference_data.py | 2 +- linajea/process_blockwise/predict_blockwise.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/linajea/config/predict.py b/linajea/config/predict.py index 461d770..4c0eb74 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -15,6 +15,7 @@ class PredictTrackingConfig(PredictConfig): write_to_zarr = attr.ib(type=bool, default=False) write_to_db = attr.ib(type=bool, default=True) write_db_from_zarr = attr.ib(type=bool, default=False) + no_db_access = attr.ib(type=bool, default=False) processes_per_worker = attr.ib(type=int, default=1) output_zarr_prefix = attr.ib(type=str, default=".") @@ -24,6 +25,8 @@ def __attrs_post_init__(self): assert not self.write_db_from_zarr or \ self.path_to_script_db_from_zarr, \ "supply path_to_script_db_from_zarr if write_db_from_zarr is used!" + assert not ((self.write_to_db or self.write_db_from_zarr) and self.no_db_access), \ + "no_db_access can only be set if no data is written to db (it then disables db done checks for write to zarr)" @attr.s(kw_only=True) class PredictCellCycleConfig(PredictConfig): diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py index f7b4185..8743043 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/get_next_inference_data.py @@ -49,7 +49,7 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): 'cell_score_threshold': inference.cell_score_threshold} for sample in inference.data_sources: sample = deepcopy(sample) - if sample.db_name is None: + if sample.db_name is None and not config.predict.no_db_access: sample.db_name = checkOrCreateDB( config.general.db_host, config.general.setup_dir, diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index f5ddcff..eab6b2d 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -152,7 +152,7 @@ def predict_blockwise( block_read_roi, block_write_roi, process_function=lambda: predict_worker(linajea_config), - check_function=lambda b: all([f(b) for f in cf]), + check_function=None if linajea_config.predict.no_db_access else lambda b: all([f(b) for f in cf]), num_workers=linajea_config.predict.job.num_workers, read_write_conflict=False, max_retries=0, From 137a45676529c21aa0deaa7ab388e135d5a15ce1 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:07:00 +0100 Subject: [PATCH 138/263] config: add flag to allow test time augment repetitions --- linajea/config/predict.py | 1 + 1 file changed, 1 insertion(+) diff --git a/linajea/config/predict.py b/linajea/config/predict.py index 4c0eb74..2939cae 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -33,3 +33,4 @@ class PredictCellCycleConfig(PredictConfig): batch_size = attr.ib(type=int) max_samples = attr.ib(type=int, default=None) prefix = attr.ib(type=str, default="") + test_time_reps = attr.ib(type=int, default=1) From 78df1818b7012ef7981455f641376c473b0deebb Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:10:55 +0100 Subject: [PATCH 139/263] add option to transform ILP features with (non-linear) function before solving --- linajea/config/solve.py | 2 ++ linajea/tracking/solver.py | 57 ++++++++++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index daad0ed..3077ee4 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -66,6 +66,7 @@ class SolveParametersMinimalConfig: # max_cell_move: currently use edge_move_threshold from extract max_cell_move = attr.ib(type=int, default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + feature_func = attr.ib(type=str, default="noop") def valid(self): return {key: val @@ -96,6 +97,7 @@ class SolveParametersMinimalSearchConfig: context = attr.ib(type=List[List[int]]) # max_cell_move: currently use edge_move_threshold from extract max_cell_move = attr.ib(type=List[int], default=None) + feature_func = attr.ib(type=List[str], default=["noop"]) random_search = attr.ib(type=bool, default=False) num_random_configs = attr.ib(type=int, default=None) diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index b34119e..d0d8a7d 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- import logging + +import numpy as np + import pylp logger = logging.getLogger(__name__) @@ -24,6 +27,15 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, self.add_node_density_constraints = add_node_density_constraints self.block_id = block_id + if parameters.feature_func == "noop": + self.feature_func = lambda x: x + elif parameters.feature_func == "log": + self.feature_func = np.log + elif parameters.feature_func == "square": + self.feature_func = np.square + else: + raise RuntimeError("invalid feature_func parameters %s", parameters.feature_func) + self.graph = track_graph self.start_frame = frames[0] if frames else self.graph.begin self.end_frame = frames[1] if frames else self.graph.end @@ -284,14 +296,18 @@ def _check_node_close_to_roi_edge(self, node, data, distance): def _node_costs(self, node, file_weight, file_constant): # node score times a weight plus a threshold - score_costs = ((self.graph.nodes[node]['score'] * + feature = self.graph.nodes[node]['score'] + if self.feature_func == np.log: + feature += 0.001 + feature = self.feature_func(feature) + score_costs = ((feature * self.parameters.weight_node_score) + self.parameters.selection_constant) if self.write_struct_svm: file_weight.write("{} {}\n".format( self.node_selected[node], - self.graph.nodes[node]['score'])) + feature)) file_constant.write("{} 1\n".format(self.node_selected[node])) return score_costs @@ -301,11 +317,16 @@ def _split_costs(self, node, file_weight, file_constant): if self.parameters.cell_cycle_key is None: if self.write_struct_svm: file_constant.write("{} 1\n".format(self.node_split[node])) + file_weight.write("{} 0\n".format(self.node_split[node])) return 1 + feature = self.graph.nodes[node][self.parameters.cell_cycle_key+"mother"] + if self.feature_func == np.log: + feature += 0.001 + feature = self.feature_func(feature) split_costs = ( ( # self.graph.nodes[node][self.parameters.cell_cycle_key][0] * - self.graph.nodes[node][self.parameters.cell_cycle_key+"mother"] * + feature * self.parameters.weight_division) + self.parameters.division_constant) @@ -313,7 +334,7 @@ def _split_costs(self, node, file_weight, file_constant): file_weight.write("{} {}\n".format( self.node_split[node], # self.graph.nodes[node][self.parameters.cell_cycle_key][0] - self.graph.nodes[node][self.parameters.cell_cycle_key+"mother"] + feature )) file_constant.write("{} 1\n".format(self.node_split[node])) @@ -323,19 +344,23 @@ def _child_costs(self, node, file_weight_or_constant): # split score times a weight if self.parameters.cell_cycle_key is None: if self.write_struct_svm: - file_weight_or_constant.write("{} 1\n".format( + file_weight_or_constant.write("{} 0\n".format( self.node_child[node])) return 0 + feature = self.graph.nodes[node][self.parameters.cell_cycle_key+"daughter"] + if self.feature_func == np.log: + feature += 0.001 + feature = self.feature_func(feature) split_costs = ( # self.graph.nodes[node][self.parameters.cell_cycle_key][1] * - self.graph.nodes[node][self.parameters.cell_cycle_key+"daughter"] * + feature * self.parameters.weight_child) if self.write_struct_svm: file_weight_or_constant.write("{} {}\n".format( self.node_child[node], # self.graph.nodes[node][self.parameters.cell_cycle_key][1] - self.graph.nodes[node][self.parameters.cell_cycle_key+"daughter"] + feature )) return split_costs @@ -344,19 +369,23 @@ def _continuation_costs(self, node, file_weight_or_constant): # split score times a weight if self.parameters.cell_cycle_key is None: if self.write_struct_svm: - file_weight_or_constant.write("{} 1\n".format( + file_weight_or_constant.write("{} 0\n".format( self.node_continuation[node])) return 0 + feature = self.graph.nodes[node][self.parameters.cell_cycle_key+"normal"] + if self.feature_func == np.log: + feature += 0.001 + feature = self.feature_func(feature) continuation_costs = ( # self.graph.nodes[node][self.parameters.cell_cycle_key][2] * - self.graph.nodes[node][self.parameters.cell_cycle_key+"normal"] * + feature * self.parameters.weight_continuation) if self.write_struct_svm: file_weight_or_constant.write("{} {}\n".format( self.node_continuation[node], # self.graph.nodes[node][self.parameters.cell_cycle_key][2] - self.graph.nodes[node][self.parameters.cell_cycle_key+"normal"] + feature )) return continuation_costs @@ -365,13 +394,17 @@ def _edge_costs(self, edge, file_weight): # edge score times a weight # TODO: normalize node and edge scores to a specific range and # ordinality? - edge_costs = (self.graph.edges[edge]['prediction_distance'] * + feature = self.graph.edges[edge]['prediction_distance'] + if self.feature_func == np.log: + feature += 0.001 + feature = self.feature_func(feature) + edge_costs = (feature * self.parameters.weight_edge_score) if self.write_struct_svm: file_weight.write("{} {}\n".format( self.edge_selected[edge], - self.graph.edges[edge]['prediction_distance'])) + feature)) return edge_costs From 6b7dcc785bf82fa693f50b6018b3fe4dddf27d4a Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:13:41 +0100 Subject: [PATCH 140/263] config solve: update solve search params computation --- linajea/config/solve.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 3077ee4..1eda18f 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -166,8 +166,8 @@ def write_solve_parameters_configs(parameters_search, non_minimal): params = {k:v for k,v in attr.asdict(parameters_search).items() if v is not None} - del params['random_search'] - del params['num_random_configs'] + params.pop('random_search', None) + params.pop('num_random_configs', None) if parameters_search.random_search: search_configs = [] @@ -178,14 +178,15 @@ def write_solve_parameters_configs(parameters_search, non_minimal): conf = {} for k, v in params.items(): if not isinstance(v, list): - conf[k] = v + value = v elif len(v) == 1: - conf[k] = v[0] - elif isinstance(v[0], str): - rnd = random.choice(v) - if rnd == "": - rnd = None - conf[k] = rnd + value = v[0] + elif isinstance(v[0], str) or len(v) > 2: + value = random.choice(v) + elif len(v) == 2 and isinstance(v[0], list) and isinstance(v[1], list) and \ + isinstance(v[0][0], str) and isinstance(v[1][0], str): + subset = random.choice(v) + value = random.choice(subset) else: assert len(v) == 2, \ "possible options per parameter for random search: " \ @@ -193,15 +194,29 @@ def write_solve_parameters_configs(parameters_search, non_minimal): "set of string values ({})".format(v) if isinstance(v[0], list): idx = random.randrange(len(v[0])) - conf[k] = random.uniform(v[0][idx], v[1][idx]) + value = random.uniform(v[0][idx], v[1][idx]) else: - conf[k] = random.uniform(v[0], v[1]) + value = random.uniform(v[0], v[1]) + if value == "": + value = None + conf[k] = value search_configs.append(conf) else: + if params.get('cell_cycle_key') == '': + params['cell_cycle_key'] = None + elif isinstance(params.get('cell_cycle_key'), list) and \ + '' in params['cell_cycle_key']: + params['cell_cycle_key'] = [k if k != '' else None + for k in params['cell_cycle_key']] + search_configs = [ dict(zip(params.keys(), x)) for x in itertools.product(*params.values())] + if parameters_search.num_random_configs: + random.shuffle(search_configs) + search_configs = search_configs[:parameters_search.num_random_configs] + configs = [] for config_vals in search_configs: logger.debug("Config vals %s" % str(config_vals)) From 7613cc9a7ec2c17ac05afce98265f8feebf8a526 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:17:23 +0100 Subject: [PATCH 141/263] update writing of SSVM data --- linajea/config/solve.py | 2 +- linajea/process_blockwise/solve_blockwise.py | 7 ++- linajea/tracking/solver.py | 50 +++++++++++--------- linajea/tracking/track.py | 5 +- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 1eda18f..b4c8a85 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -237,7 +237,7 @@ class SolveConfig: parameters = attr.ib(converter=convert_solve_params_list(), default=None) parameters_search = attr.ib(converter=convert_solve_search_params(), default=None) non_minimal = attr.ib(type=bool, default=False) - write_struct_svm = attr.ib(type=bool, default=False) + write_struct_svm = attr.ib(type=str, default=None) check_node_close_to_roi = attr.ib(type=bool, default=True) add_node_density_constraints = attr.ib(type=bool, default=False) timeout = attr.ib(type=int, default=120) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index 3d2f36d..d69ec8a 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -87,7 +87,7 @@ def solve_blockwise(linajea_config): # Note: in the case of a set of parameters, # we are assuming that none of the individual parameters are # half done and only checking the hash for each block - check_function=lambda b: check_function( + check_function=None if linajea_config.solve.write_struct_svm else lambda b: check_function( b, step_name, db_name, @@ -195,6 +195,11 @@ def solve_in_block(linajea_config, else: track(graph, linajea_config, selected_keys, frames=frames, block_id=block.block_id) + + if linajea_config.solve.write_struct_svm: + logger.info("wrote struct svm data, database not updated") + return 0 + start_time = time.time() graph.update_edge_attrs( roi=write_roi, diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index d0d8a7d..6c1c264 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import logging +import os +import time import numpy as np @@ -23,6 +25,9 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, # and end_frame is exclusive. Defaults to track_graph.begin, # track_graph.end self.write_struct_svm = write_struct_svm + if self.write_struct_svm: + assert isinstance(self.write_struct_svm, str) + os.makedirs(self.write_struct_svm, exist_ok=True) self.check_node_close_to_roi = check_node_close_to_roi self.add_node_density_constraints = add_node_density_constraints self.block_id = block_id @@ -107,12 +112,12 @@ def _create_indicators(self): # 3. disappear # 4. split if self.write_struct_svm: - node_selected_file = open(f"node_selected_b{self.block_id}", 'w') - node_appear_file = open(f"node_appear_b{self.block_id}", 'w') - node_disappear_file = open(f"node_disappear_b{self.block_id}", 'w') - node_split_file = open(f"node_split_b{self.block_id}", 'w') - node_child_file = open(f"node_child_b{self.block_id}", 'w') - node_continuation_file = open(f"node_continuation_b{self.block_id}", 'w') + node_selected_file = open(f"{self.write_struct_svm}/node_selected_b{self.block_id}", 'w') + node_appear_file = open(f"{self.write_struct_svm}/node_appear_b{self.block_id}", 'w') + node_disappear_file = open(f"{self.write_struct_svm}/node_disappear_b{self.block_id}", 'w') + node_split_file = open(f"{self.write_struct_svm}/node_split_b{self.block_id}", 'w') + node_child_file = open(f"{self.write_struct_svm}/node_child_b{self.block_id}", 'w') + node_continuation_file = open(f"{self.write_struct_svm}/node_continuation_b{self.block_id}", 'w') else: node_selected_file = None node_appear_file = None @@ -146,7 +151,7 @@ def _create_indicators(self): node_continuation_file.close() if self.write_struct_svm: - edge_selected_file = open(f"edge_selected_b{self.block_id}", 'w') + edge_selected_file = open(f"{self.write_struct_svm}/edge_selected_b{self.block_id}", 'w') for edge in self.graph.edges(): if self.write_struct_svm: edge_selected_file.write("{} {} {}\n".format(edge[0], edge[1], self.num_vars)) @@ -166,17 +171,17 @@ def _set_objective(self): # node selection and cell cycle costs if self.write_struct_svm: node_selected_weight_file = open( - f"features_node_selected_weight_b{self.block_id}", 'w') + f"{self.write_struct_svm}/features_node_selected_weight_b{self.block_id}", 'w') node_selected_constant_file = open( - f"features_node_selected_constant_b{self.block_id}", 'w') + f"{self.write_struct_svm}/features_node_selected_constant_b{self.block_id}", 'w') node_split_weight_file = open( - f"features_node_split_weight_b{self.block_id}", 'w') + f"{self.write_struct_svm}/features_node_split_weight_b{self.block_id}", 'w') node_split_constant_file = open( - f"features_node_split_constant_b{self.block_id}", 'w') + f"{self.write_struct_svm}/features_node_split_constant_b{self.block_id}", 'w') node_child_weight_or_constant_file = open( - f"features_node_child_weight_or_constant_b{self.block_id}", 'w') + f"{self.write_struct_svm}/features_node_child_weight_or_constant_b{self.block_id}", 'w') node_continuation_weight_or_constant_file = open( - f"features_node_continuation_weight_or_constant_b{self.block_id}", 'w') + f"{self.write_struct_svm}/features_node_continuation_weight_or_constant_b{self.block_id}", 'w') else: node_selected_weight_file = None node_selected_constant_file = None @@ -218,9 +223,10 @@ def _set_objective(self): # edge selection costs if self.write_struct_svm: edge_selected_weight_file = open( - f"features_edge_selected_weight_b{self.block_id}", 'w') + f"{self.write_struct_svm}/features_edge_selected_weight_b{self.block_id}", 'w') else: edge_selected_weight_file = None + for edge in self.graph.edges(): objective.set_coefficient( self.edge_selected[edge], @@ -231,8 +237,8 @@ def _set_objective(self): # node appear (skip first frame) if self.write_struct_svm: - appear_file = open(f"features_node_appear_b{self.block_id}", 'w') - disappear_file = open(f"features_node_disappear_b{self.block_id}", 'w') + appear_file = open(f"{self.write_struct_svm}/features_node_appear_b{self.block_id}", 'w') + disappear_file = open(f"{self.write_struct_svm}/features_node_disappear_b{self.block_id}", 'w') for t in range(self.start_frame + 1, self.end_frame): for node in self.graph.cells_by_frame(t): objective.set_coefficient( @@ -443,7 +449,7 @@ def _add_edge_constraints(self): logger.debug("setting edge constraints") if self.write_struct_svm: - edge_constraint_file = open(f"constraints_edge_b{self.block_id}", 'w') + edge_constraint_file = open(f"{self.write_struct_svm}/constraints_edge_b{self.block_id}", 'w') cnstr = "2*{} -1*{} -1*{} <= 0\n" for e in self.graph.edges(): @@ -478,7 +484,7 @@ def _add_inter_frame_constraints(self, t): # one or two to the next frame. This includes the special "appear" and # "disappear" edges. if self.write_struct_svm: - node_edge_constraint_file = open(f"constraints_node_edge_b{self.block_id}", 'a') + node_edge_constraint_file = open(f"{self.write_struct_svm}/constraints_node_edge_b{self.block_id}", 'a') for node in self.graph.cells_by_frame(t): # we model this as three constraints: # sum(prev) - node = 0 # exactly one prev edge, @@ -574,7 +580,7 @@ def _add_inter_frame_constraints(self, t): # Ensure that the split indicator is set for every cell that splits # into two daughter cells. if self.write_struct_svm: - node_split_constraint_file = open(f"constraints_node_split_b{self.block_id}", 'a') + node_split_constraint_file = open(f"{self.write_struct_svm}/constraints_node_split_b{self.block_id}", 'a') for node in self.graph.cells_by_frame(t): # I.e., each node with two forwards edges is a split node. @@ -643,7 +649,7 @@ def _add_cell_cycle_constraints(self): # split(v) + selected(e) - child(u) <= 1 if self.write_struct_svm: - edge_split_constraint_file = open(f"constraints_edge_split_b{self.block_id}", 'a') + edge_split_constraint_file = open(f"{self.write_struct_svm}/constraints_edge_split_b{self.block_id}", 'a') for e in self.graph.edges(): # if e is selected, u and v have to be selected @@ -693,7 +699,7 @@ def _add_cell_cycle_constraints(self): # Constraint for each node: # split + child + continuation - selected = 0 if self.write_struct_svm: - node_cell_cycle_constraint_file = open(f"constraints_node_cell_cycle_b{self.block_id}", 'a') + node_cell_cycle_constraint_file = open(f"{self.write_struct_svm}/constraints_node_cell_cycle_b{self.block_id}", 'a') for node in self.graph.nodes(): cycle_set_constraint = pylp.LinearConstraint() cycle_set_constraint.set_coefficient(self.node_split[node], 1) @@ -743,7 +749,7 @@ def _add_node_density_constraints_objective(self): r = filter_sz/2 radius = {30: 35, 60: 25, 100: 15, 1000:10} if self.write_struct_svm: - node_density_constraint_file = open(f"constraints_node_density_b{self.block_id}", 'w') + node_density_constraint_file = open(f"{self.write_struct_svm}/constraints_node_density_b{self.block_id}", 'w') for t in range(self.start_frame, self.end_frame): kd_data = [pos for _, pos in nodes_by_t[t]] kd_tree = cKDTree(kd_data) diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 8a2f223..1d37f25 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -100,7 +100,10 @@ def track(graph, config, selected_key, frame_key='t', frames=None, else: solver.update_objective(parameter, key) - logger.debug("Solving for key %s", str(key)) + if config.solve.write_struct_svm: + logger.info("wrote struct svm data, skipping solving") + break + logger.info("Solving for key %s", str(key)) start_time = time.time() solver.solve() end_time = time.time() From 4e5396e27f3d4b5f43d8e8a85af563f29488b254 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:47:55 +0100 Subject: [PATCH 142/263] config: add flag for greedy solving --- linajea/config/solve.py | 17 +++++++++++++++++ linajea/process_blockwise/solve_blockwise.py | 7 +++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index b4c8a85..7cbf69b 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -237,6 +237,7 @@ class SolveConfig: parameters = attr.ib(converter=convert_solve_params_list(), default=None) parameters_search = attr.ib(converter=convert_solve_search_params(), default=None) non_minimal = attr.ib(type=bool, default=False) + greedy = attr.ib(type=bool, default=False) write_struct_svm = attr.ib(type=str, default=None) check_node_close_to_roi = attr.ib(type=bool, default=True) add_node_density_constraints = attr.ib(type=bool, default=False) @@ -255,6 +256,22 @@ def __attrs_post_init__(self): self.parameters = write_solve_parameters_configs( self.parameters_search, non_minimal=self.non_minimal) + if self.greedy: + config_vals = { + "weight_node_score": 0, + "selection_constant": 0, + "track_cost": 0, + "weight_division": 0, + "division_constant": 0, + "weight_child": 0, + "weight_continuation": 0, + "weight_edge_score": 0, + "block_size": [15, 512, 512, 712], + "context": [2, 100, 100, 100] + } + if self.parameters[0].cell_cycle_key is not None: + config_vals['cell_cycle_key'] = self.parameters[0].cell_cycle_key + self.parameters = [SolveParametersMinimalConfig(**config_vals)] # block size and context must be the same for all parameters! block_size = self.parameters[0].block_size context = self.parameters[0].context diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index d69ec8a..a3a7f39 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -6,7 +6,7 @@ from .daisy_check_functions import ( check_function, write_done, check_function_all_blocks, write_done_all_blocks) -from linajea.tracking import track, nm_track +from linajea.tracking import track, nm_track, greedy_track logger = logging.getLogger(__name__) @@ -190,7 +190,10 @@ def solve_in_block(linajea_config, frames = [read_roi.get_offset()[0], read_roi.get_offset()[0] + read_roi.get_shape()[0]] - if linajea_config.solve.non_minimal: + if linajea_config.solve.greedy: + greedy_track(graph, selected_keys[0], + cell_indicator_threshold=0.2) + elif linajea_config.solve.non_minimal: nm_track(graph, linajea_config, selected_keys, frames=frames) else: track(graph, linajea_config, selected_keys, From b6b6375a379d22ce818eba2e75c4f3e5a44064a4 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:50:02 +0100 Subject: [PATCH 143/263] config: add flag to remove nodes with low score before solving --- linajea/config/solve.py | 1 + linajea/process_blockwise/solve_blockwise.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 7cbf69b..70e5ec2 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -242,6 +242,7 @@ class SolveConfig: check_node_close_to_roi = attr.ib(type=bool, default=True) add_node_density_constraints = attr.ib(type=bool, default=False) timeout = attr.ib(type=int, default=120) + clip_low_score = attr.ib(type=float, default=None) def __attrs_post_init__(self): assert self.parameters is not None or \ diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index a3a7f39..9adc1a5 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -177,6 +177,15 @@ def solve_in_block(linajea_config, ] graph.remove_nodes_from(dangling_nodes) + if linajea_config.solve.clip_low_score: + logger.info("Dropping low score nodes") + low_score_nodes = [ + n + for n, data in graph.nodes(data=True) + if data['score'] < linajea_config.solve.clip_low_score + ] + graph.remove_nodes_from(low_score_nodes) + num_nodes = graph.number_of_nodes() num_edges = graph.number_of_edges() logger.info("Reading graph with %d nodes and %d edges took %s seconds", From cc4c887e3d131dad20c7a893b270227ed64a688e Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:51:35 +0100 Subject: [PATCH 144/263] config train: add variable radius for add_parent_vectors --- linajea/config/train.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/linajea/config/train.py b/linajea/config/train.py index 5de1ce5..440570f 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -45,6 +45,8 @@ def __attrs_post_init__(self): class TrainTrackingConfig(TrainConfig): # (radius for binary map -> *2) (optional) parent_radius = attr.ib(type=List[float]) + # context to be able to get location of parents during add_parent_vectors + move_radius = attr.ib(type=float, default=None) # (sigma for Gauss -> ~*4 (5 in z -> in 3 slices)) (optional) rasterize_radius = attr.ib(type=List[float]) augment = attr.ib(converter=ensure_cls(AugmentTrackingConfig)) From b9b5ed02e573de9bf343498b484bcf20479418f5 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:52:40 +0100 Subject: [PATCH 145/263] config train: split par vec transition into two params (factor, offset) --- linajea/config/train.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linajea/config/train.py b/linajea/config/train.py index 440570f..0f073d9 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -50,7 +50,8 @@ class TrainTrackingConfig(TrainConfig): # (sigma for Gauss -> ~*4 (5 in z -> in 3 slices)) (optional) rasterize_radius = attr.ib(type=List[float]) augment = attr.ib(converter=ensure_cls(AugmentTrackingConfig)) - parent_vectors_loss_transition = attr.ib(type=int, default=50000) + parent_vectors_loss_transition_factor = attr.ib(type=float, default=0.01) + parent_vectors_loss_transition_offset = attr.ib(type=int, default=20000) use_radius = attr.ib(type=Dict[int, int], default=None, converter=use_radius_converter()) cell_density = attr.ib(default=None) From ad410f91f4f1cc810f21fbd9c634682547cd569e Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:53:44 +0100 Subject: [PATCH 146/263] config data: extend track range parsing --- linajea/config/train_test_validate_data.py | 24 +++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/linajea/config/train_test_validate_data.py b/linajea/config/train_test_validate_data.py index 3ef6983..e8ee0bb 100644 --- a/linajea/config/train_test_validate_data.py +++ b/linajea/config/train_test_validate_data.py @@ -1,8 +1,9 @@ -import daisy - +from copy import deepcopy +import os from typing import List import attr +import daisy from .data import (DataSourceConfig, DataDBMetaConfig, @@ -19,18 +20,31 @@ def __attrs_post_init__(self): if d.roi is None: if self.roi is None: # if roi/voxelsize not set, use info from file - d.roi = d.datafile.file_roi + d.roi = deepcopy(d.datafile.file_roi) else: # if data sample specific roi/voxelsize not set, # use general one - d.roi = self.roi + d.roi = deepcopy(self.roi) file_roi = daisy.Roi(offset=d.datafile.file_roi.offset, shape=d.datafile.file_roi.shape) roi = daisy.Roi(offset=d.roi.offset, shape=d.roi.shape) roi = roi.intersect(file_roi) if d.datafile.file_track_range is not None: - begin_frame, end_frame = d.datafile.file_track_range + if isinstance(d.datafile.file_track_range, dict): + if os.path.isdir(d.datafile.filename): + # TODO check this for training + begin_frame, end_frame = list(d.datafile.file_track_range.values())[0] + for _, v in d.datafile.file_track_range.items(): + if v[0] < begin_frame: + begin_frame = v[0] + if v[1] > end_frame: + end_frame = v[1] + else: + begin_frame, end_frame = d.datafile.file_track_range[ + os.path.basename(d.datafile.filename)] + else: + begin_frame, end_frame = d.datafile.file_track_range track_range_roi = daisy.Roi( offset=[begin_frame, None, None, None], shape=[end_frame-begin_frame+1, None, None, None]) From 45f1daaa39121a8f0d2a2f0048c3c9de4c0f9527 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:56:14 +0100 Subject: [PATCH 147/263] config unet: add params to modify structure/type of unet --- linajea/config/unet_config.py | 28 ++++++++++++++++++++-------- linajea/config/utils.py | 10 ++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/linajea/config/unet_config.py b/linajea/config/unet_config.py index 2581c7e..82662fd 100644 --- a/linajea/config/unet_config.py +++ b/linajea/config/unet_config.py @@ -5,7 +5,8 @@ from .utils import (ensure_cls, _check_nd_shape, _int_list_validator, - _list_int_list_validator) + _list_int_list_validator, + _check_possible_nested_lists) @attr.s(kw_only=True) @@ -22,14 +23,20 @@ class UnetConfig: downsample_factors = attr.ib(type=List[List[int]], validator=_list_int_list_validator) kernel_size_down = attr.ib(type=List[List[int]], - validator=_list_int_list_validator) + validator=_check_possible_nested_lists) kernel_size_up = attr.ib(type=List[List[int]], - validator=_list_int_list_validator) - upsampling = attr.ib(type=str, default="uniform_transposed_conv", - validator=attr.validators.in_([ - "transposed_conv", "resize_conv", - "uniform_transposed_conv"])) - constant_upsample = attr.ib(type=bool) + validator=_check_possible_nested_lists) + upsampling = attr.ib(type=str, default=None, + validator=attr.validators.optional(attr.validators.in_([ + "transposed_conv", + "sep_transposed_conv", # depthwise + pixelwise + "resize_conv", + "uniform_transposed_conv", + "pixel_shuffle", + "trilinear", # aka 3d bilinear + "nearest" + ]))) + constant_upsample = attr.ib(type=bool, default=None) nms_window_shape = attr.ib(type=List[int], validator=[_int_list_validator, _check_nd_shape(3)]) @@ -41,3 +48,8 @@ class UnetConfig: unet_style = attr.ib(type=str, default='split') num_fmaps = attr.ib(type=int) cell_indicator_weighted = attr.ib(type=bool, default=True) + cell_indicator_cutoff = attr.ib(type=float, default=0.01) + chkpt_parents = attr.ib(type=str, default=None) + chkpt_cell_indicator = attr.ib(type=str, default=None) + latent_temp_conv = attr.ib(type=bool, default=False) + train_only_cell_indicator = attr.ib(type=bool, default=False) diff --git a/linajea/config/utils.py b/linajea/config/utils.py index ef7b611..e400ea3 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -69,6 +69,16 @@ def _check_shape(self, attribute, value): member_validator=_int_list_validator, iterable_validator=attr.validators.instance_of(list)) +def _check_possible_nested_lists(self, attribute, value): + try: + attr.validators.deep_iterable( + member_validator=_int_list_validator, + iterable_validator=attr.validators.instance_of(list))(self,attribute, value) + except: + attr.validators.deep_iterable( + member_validator=_list_int_list_validator, + iterable_validator=attr.validators.instance_of(list))(self, attribute, value) + def maybe_fix_config_paths_to_machine_and_load(config): config_dict = toml.load(config) config_dict["path"] = config From a0b843d5449281ce728cef500e601a4631bcd838 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 15:59:40 +0100 Subject: [PATCH 148/263] eval: warn if frame missing in cand. tree --- linajea/evaluation/analyze_candidates.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/linajea/evaluation/analyze_candidates.py b/linajea/evaluation/analyze_candidates.py index 5e2e8a1..5e11294 100644 --- a/linajea/evaluation/analyze_candidates.py +++ b/linajea/evaluation/analyze_candidates.py @@ -64,8 +64,8 @@ def get_edge_recall( num_matches = 0 num_gt_edges = 0 for source_id, target_id in gt_graph.edges(): - source_node = gt_graph.nodes(source_id).data() - target_node = gt_graph.nodes(target_id).data() + source_node = gt_graph.nodes[source_id] + target_node = gt_graph.nodes[target_id] if 't' not in target_node: logger.warn("Target node %s is not in roi" % target_id) continue @@ -73,7 +73,13 @@ def get_edge_recall( source_frame = source_node['t'] target_frame = target_node['t'] assert source_frame - 1 == target_frame + if source_frame not in cand_kd_trees: + logger.warn("Frame %s not in candidate graph" % source_frame) + break source_kd_tree = cand_kd_trees[source_frame] + if target_frame not in cand_kd_trees: + logger.warn("Frame %s not in candidate graph" % target_frame) + break target_kd_tree = cand_kd_trees[target_frame] source_neighbors = source_kd_tree.query_ball_point( [source_node[dim] for dim in ['z', 'y', 'x']], match_distance) From 21a2af522d4b46111e2caeaaa4135c1fd70c8aa6 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 16:03:14 +0100 Subject: [PATCH 149/263] eval: add func to get sorted results based on db name --- linajea/evaluation/__init__.py | 2 +- linajea/evaluation/analyze_results.py | 46 +++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/linajea/evaluation/__init__.py b/linajea/evaluation/__init__.py index 097cf36..4abbd4c 100644 --- a/linajea/evaluation/__init__.py +++ b/linajea/evaluation/__init__.py @@ -7,7 +7,7 @@ from .validation_metric import validation_score from .analyze_results import ( get_result, get_results, get_best_result, - get_results_sorted, get_best_result_with_config, get_result_id, + get_results_sorted, get_best_result_with_config, get_result_id, get_results_sorted_db, get_best_result_per_setup, get_tgmm_results, get_best_tgmm_result, diff --git a/linajea/evaluation/analyze_results.py b/linajea/evaluation/analyze_results.py index 043f4aa..09ad0cf 100644 --- a/linajea/evaluation/analyze_results.py +++ b/linajea/evaluation/analyze_results.py @@ -201,8 +201,10 @@ def get_results_sorted(config, results_df['sum_errors'] = sum([results_df[col]*weight for col, weight in zip(score_columns, score_weights)]) - results_df['sum_divs'] = sum([results_df[col]*weight for col, weight - in zip(score_columns[-2:], score_weights[-2:])]) + results_df['sum_divs'] = sum( + [results_df[col]*weight for col, weight + in zip(score_columns[-2:], score_weights[-2:])]) + results_df = results_df.astype({"sum_errors": int, "sum_divs": int}) ascending = True if sort_by == "matched_edges": ascending = False @@ -232,6 +234,46 @@ def get_best_result_with_config(config, return best_result +def get_results_sorted_db(db_name, + db_host, + filter_params=None, + score_columns=None, + score_weights=None, + sort_by="sum_errors"): + if not score_columns: + score_columns = ['fn_edges', 'identity_switches', + 'fp_divisions', 'fn_divisions'] + if not score_weights: + score_weights = [1.]*len(score_columns) + + logger.info("checking db: %s", db_name) + + candidate_db = CandidateDatabase(db_name, db_host, 'r') + scores = candidate_db.get_scores(filters=filter_params) + + if len(scores) == 0: + raise RuntimeError("no scores found!") + + results_df = pandas.DataFrame(scores) + logger.debug("data types of results_df dataframe columns: %s" + % str(results_df.dtypes)) + if 'param_id' in results_df: + results_df['_id'] = results_df['param_id'] + results_df.set_index('param_id', inplace=True) + + results_df['sum_errors'] = sum([results_df[col]*weight for col, weight + in zip(score_columns, score_weights)]) + results_df['sum_divs'] = sum( + [results_df[col]*weight for col, weight + in zip(score_columns[-2:], score_weights[-2:])]) + results_df = results_df.astype({"sum_errors": int, "sum_divs": int}) + ascending = True + if sort_by == "matched_edges": + ascending = False + results_df.sort_values(sort_by, ascending=ascending, inplace=True) + return results_df + + def get_result_id( config, parameters_id): From e8d8c2ed43e352822362db328c07950ced5c7c5d Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 16:06:11 +0100 Subject: [PATCH 150/263] eval: check roi --- linajea/evaluation/evaluate_setup.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index dfc4b03..93512d5 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -22,6 +22,12 @@ def evaluate_setup(linajea_config): data = linajea_config.inference.data_source db_name = data.db_name db_host = linajea_config.general.db_host + if linajea_config.evaluate.parameters.roi is not None: + assert linajea_config.evaluate.parameters.roi.shape[0] <= data.roi.shape[0], \ + "your evaluation ROI is larger than your data roi!" + data.roi = linajea_config.evaluate.parameters.roi + else: + linajea_config.evaluate.parameters.roi = data.roi evaluate_roi = daisy.Roi(offset=data.roi.offset, shape=data.roi.shape) From d405e81e6e6f44f649788dcb7e76e9cf7a8f2a85 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 16:29:02 +0100 Subject: [PATCH 151/263] update parent_vector drawing create mask for all current nodes in one step (only one call to enlarge_binary_map) then for each node locate pointwise mask and draw vectors --- linajea/gunpowder/add_parent_vectors.py | 130 +++++++++++++++++++----- 1 file changed, 105 insertions(+), 25 deletions(-) diff --git a/linajea/gunpowder/add_parent_vectors.py b/linajea/gunpowder/add_parent_vectors.py index f677098..deb7807 100644 --- a/linajea/gunpowder/add_parent_vectors.py +++ b/linajea/gunpowder/add_parent_vectors.py @@ -50,7 +50,7 @@ def setup(self): self.enable_autoskip() def prepare(self, request): - context = np.ceil(self.radius).astype(np.int) + context = np.ceil(self.radius).astype(int) dims = self.array_spec.roi.dims() if len(context) == 1: @@ -103,7 +103,8 @@ def process(self, batch, request): data_roi, voxel_size, enlarged_vol_roi.get_begin(), - self.radius) + self.radius, + request[self.points].roi) # create array and crop it to requested roi spec = self.spec[self.array].copy() @@ -136,7 +137,7 @@ def process(self, batch, request): % (self.points, request_roi)) def __draw_parent_vectors( - self, points, data_roi, voxel_size, offset, radius): + self, points, data_roi, voxel_size, offset, radius, final_roi): # 4D: t, z, y, x shape = data_roi.get_shape() @@ -164,17 +165,28 @@ def __draw_parent_vectors( coords[2, :] += offset[3] parent_vectors = np.zeros_like(coords) - mask = np.zeros(shape, dtype=np.bool) + mask = np.zeros(shape, dtype=bool) logger.debug( "Adding parent vectors for %d points...", points.num_vertices()) + if points.num_vertices() == 0: + return parent_vectors, mask.astype(np.float32) + empty = True cnt = 0 total = 0 + + avg_radius = [] for point in points.nodes: + if point.attrs.get('value') is not None: + r = point.attrs['value'][0] + avg_radius.append(point.attrs['value'][0]) + avg_radius = (int(np.ceil(np.mean(avg_radius))) + if len(avg_radius) > 0 else radius) + for point in points.nodes: # get the voxel coordinate, 'Coordinate' ensures integer v = Coordinate(point.location/voxel_size) @@ -184,48 +196,112 @@ def __draw_parent_vectors( v) continue - total += 1 if point.attrs.get("parent_id") is None: logger.warning("Skipping point without parent") continue if not points.contains(point.attrs["parent_id"]): - logger.warning( - "parent %d of %d not in %s", - point.attrs["parent_id"], - point.id, self.points) - logger.debug("request roi: %s" % data_roi) + if final_roi.contains(point.location): + logger.warning( + "parent %d of %d not in %s", + point.attrs["parent_id"], + point.id, self.points) + logger.debug("request roi: %s" % data_roi) if not self.dense: continue empty = False # get the voxel coordinate relative to output array start v -= data_roi.get_begin() - logger.debug( - "Rasterizing point %s at %s", - point.location, - point.location/voxel_size - data_roi.get_begin()) + mask[v] = 1 - point_mask = np.zeros(shape, dtype=np.bool) - point_mask[v] = 1 + enlarge_binary_map( + mask, + avg_radius, + voxel_size, + in_place=True) - r = radius - if point.attrs.get('value') is not None: - r = point.attrs['value'][0] + mask_tmp = np.zeros(shape, dtype=bool) + mask_tmp[shape//2] = 1 + enlarge_binary_map( + mask_tmp, + avg_radius, + voxel_size, + in_place=True) + + coords = np.argwhere(mask_tmp) + _, z_min, y_min, x_min = coords.min(axis=0) + _, z_max, y_max, x_max = coords.max(axis=0) + mask_cut = mask_tmp[:, + z_min:z_max+1, + y_min:y_max+1, + x_min:x_max+1] + logger.info("mask cut %s", mask_cut.shape) - enlarge_binary_map( - point_mask, - r, - voxel_size, - in_place=True) + point_mask = np.zeros(shape, dtype=bool) + + for point in points.nodes: + + # get the voxel coordinate, 'Coordinate' ensures integer + v = Coordinate(point.location/voxel_size) + + if not data_roi.contains(v): + continue + + total += 1 + if point.attrs.get("parent_id") is None: + continue + + if not points.contains(point.attrs["parent_id"]): + if not self.dense: + continue + empty = False + # get the voxel coordinate relative to output array start + v -= data_roi.get_begin() + + logger.debug( + "Rasterizing point %s at %s, shape %s", + point.location, + point.location/voxel_size - data_roi.get_begin(), shape) - mask = np.logical_or(mask, point_mask) if not points.contains(point.attrs["parent_id"]) and self.dense: continue cnt += 1 parent = points.node(point.attrs["parent_id"]) + s_begin = [] + s_end = [] + mc_begin = [] + mc_end = [] + for c, ms, s in zip(v, mask_cut.shape, shape): + b = c-ms//2 + if b < 0: + s_begin.append(0) + mc_begin.append(-b) + else: + s_begin.append(b) + mc_begin.append(0) + e = c+ms//2+1 + if e > s: + s_end.append(s) + mc_end.append(s-e) + else: + s_end.append(e) + mc_end.append(ms) + + slices = (slice(None), + slice(s_begin[1], s_end[1]), + slice(s_begin[2], s_end[2]), + slice(s_begin[3], s_end[3])) + mc_slices = (slice(None), + slice(mc_begin[1], mc_end[1]), + slice(mc_begin[2], mc_end[2]), + slice(mc_begin[3], mc_end[3])) + + point_mask[:] = 0 + point_mask[slices] = mask_cut[mc_slices] + parent_vectors[0][point_mask] = (parent.location[1] - coords[0][point_mask]) parent_vectors[1][point_mask] = (parent.location[2] @@ -233,6 +309,10 @@ def __draw_parent_vectors( parent_vectors[2][point_mask] = (parent.location[3] - coords[2][point_mask]) + parent_vectors[0][np.logical_not(mask)] = 0 + parent_vectors[1][np.logical_not(mask)] = 0 + parent_vectors[2][np.logical_not(mask)] = 0 + if empty: logger.warning("No parent vectors written for points %s" % points.nodes) From 5e61912ac2795b15683f8168450b113e5af12e66 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 16:40:40 +0100 Subject: [PATCH 152/263] update ctc export --- linajea/visualization/ctc/write_ctc.py | 52 +++++++++++++++++++++----- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/linajea/visualization/ctc/write_ctc.py b/linajea/visualization/ctc/write_ctc.py index f8d6cf1..e7b2929 100644 --- a/linajea/visualization/ctc/write_ctc.py +++ b/linajea/visualization/ctc/write_ctc.py @@ -41,6 +41,7 @@ def write_ctc(graph, start_frame, end_frame, shape, # assert end_frame <= graph.get_frames()[1] track_cntr = 1 + # dict, key: node, value: (trackID, parent trackID, current frame) node_to_track = {} curr_cells = set(graph.cells_by_frame(start_frame)) for c in curr_cells: @@ -48,32 +49,58 @@ def write_ctc(graph, start_frame, end_frame, shape, track_cntr += 1 prev_cells = curr_cells + # dict, key: frames, values: list of cells in frame k cells_by_t_data = {} + + # for all frames except first one (handled before) for f in range(start_frame+1, end_frame): cells_by_t_data[f] = [] curr_cells = set(graph.cells_by_frame(f)) + + for c in curr_cells: + node_to_track[c] = (track_cntr, 0, f) + track_cntr += 1 + # for all cells in previous frame for c in prev_cells: + # get edges for cell edges = list(graph.next_edges(c)) assert len(edges) <= 2, "more than two children" + + # no division if len(edges) == 1: + # get single edge e = edges[0] - assert e[0] in curr_cells, "cell missing" - assert e[1] in prev_cells, "cell missing" + # assert source in curr_cells, target in prev_cells + assert e[0] in curr_cells, "cell missing {}".format(e) + assert e[1] in prev_cells, "cell missing {}".format(e) + + # add source to node_to_track + # get trackID from target node + # get parent trackID from target node + # set frame of node node_to_track[e[0]] = ( node_to_track[e[1]][0], node_to_track[e[1]][1], f) - curr_cells.remove(e[0]) + # curr_cells.remove(e[0]) + # division elif len(edges) == 2: + # get both edges for e in edges: + # assert endpoints exist assert e[0] in curr_cells, "cell missing" assert e[1] in prev_cells, "cell missing" + # add source to node_to_track + # insert as new track/trackID + # get parent trackID from target node node_to_track[e[0]] = ( track_cntr, node_to_track[e[1]][0], f) + + # inc trackID track_cntr += 1 - curr_cells.remove(e[0]) + # curr_cells.remove(e[0]) if gt: for e in edges: @@ -87,9 +114,7 @@ def write_ctc(graph, start_frame, end_frame, shape, for d in ['z', 'y', 'x']]), np.array([dataSt[d] - dataNd[d] for d in ['z', 'y', 'x']]))) - for c in curr_cells: - node_to_track[c] = (track_cntr, 0, f) - track_cntr += 1 + prev_cells = set(graph.cells_by_frame(f)) tracks = {} @@ -115,8 +140,8 @@ def write_ctc(graph, start_frame, end_frame, shape, with open(os.path.join(out_dir, "parent_vectors.txt"), 'w') as of: for t, cs in cells_by_t_data.items(): for c in cs: - of.write("{} {} {} {} {} {} {}\n".format( - t, c[1][0], c[1][1], c[1][2], + of.write("{} {} {} {} {} {} {} {} {}\n".format( + t, c[0], node_to_track[c[0]][0], c[1][0], c[1][1], c[1][2], c[2][0], c[2][1], c[2][2])) with open(os.path.join(out_dir, txt_fn), 'w') as of: @@ -244,6 +269,15 @@ def write_ctc(graph, start_frame, end_frame, shape, if surface is not None: radii = {10000: 12, } + # radii = { + # 30: 20, + # 70: 15, + # 100: 13, + # 130: 11, + # 180: 10, + # 270: 8, + # 9999: 7, + # } for th in sorted(radii.keys()): if f < th: d = radii[th] From b0a555db7ff758910db109b6c20718395a988fa4 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 16:50:13 +0100 Subject: [PATCH 153/263] solver: take density constraint args from config --- linajea/tracking/non_minimal_solver.py | 17 +++++++++++++---- linajea/tracking/solver.py | 23 ++++++++++++++++------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/linajea/tracking/non_minimal_solver.py b/linajea/tracking/non_minimal_solver.py index 83b178b..f7e79aa 100644 --- a/linajea/tracking/non_minimal_solver.py +++ b/linajea/tracking/non_minimal_solver.py @@ -503,15 +503,18 @@ def _add_node_density_constraints_objective(self): dia = 2*rad filter_sz = 1*dia r = filter_sz/2 - radius = {30: 35, 60: 25, 100: 15, 1000:10} + if isinstance(self.add_node_density_constraints, dict): + radius = self.add_node_density_constraints + else: + radius = {30: 35, 60: 25, 100: 15, 1000:10} for t in range(self.start_frame, self.end_frame): kd_data = [pos for _, pos in nodes_by_t[t]] kd_tree = cKDTree(kd_data) if isinstance(radius, dict): - for th, val in radius.items(): + for th in sorted(list(radius.keys())): if t < int(th): - r = val + r = radius[th] break nn_nodes = kd_tree.query_ball_point(kd_data, r, p=np.inf, return_length=False) @@ -527,7 +530,13 @@ def _add_node_density_constraints_objective(self): continue nn = nodes_by_t[t][nn_id][0] constraint.set_coefficient(self.node_selected[nn], 1) - logger.debug(" neighbor pos %s %s", kd_data[nn_id], np.linalg.norm(np.array(kd_data[idx])-np.array(kd_data[nn_id]), ord=np.inf)) + logger.debug( + "neighbor pos %s %s (node %s)", + kd_data[nn_id], + np.linalg.norm(np.array(kd_data[idx]) - + np.array(kd_data[nn_id]), + ), + nn) constraint.set_coefficient(self.node_selected[node], 1) constraint.set_relation(pylp.Relation.LessEqual) constraint.set_value(1) diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index 6c1c264..2938d46 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -747,7 +747,10 @@ def _add_node_density_constraints_objective(self): dia = 2*rad filter_sz = 1*dia r = filter_sz/2 - radius = {30: 35, 60: 25, 100: 15, 1000:10} + if isinstance(self.add_node_density_constraints, dict): + radius = self.add_node_density_constraints + else: + radius = {30: 35, 60: 25, 100: 15, 1000:10} if self.write_struct_svm: node_density_constraint_file = open(f"{self.write_struct_svm}/constraints_node_density_b{self.block_id}", 'w') for t in range(self.start_frame, self.end_frame): @@ -755,11 +758,11 @@ def _add_node_density_constraints_objective(self): kd_tree = cKDTree(kd_data) if isinstance(radius, dict): - for th, val in radius.items(): + for th in sorted(list(radius.keys())): if t < int(th): - r = val + r = radius[th] break - nn_nodes = kd_tree.query_ball_point(kd_data, r, p=np.inf, + nn_nodes = kd_tree.query_ball_point(kd_data, r, return_length=False) for idx, (node, _) in enumerate(nodes_by_t[t]): @@ -768,8 +771,8 @@ def _add_node_density_constraints_objective(self): constraint = pylp.LinearConstraint() if self.write_struct_svm: cnstr = "" - logger.debug("new constraint (frame %s) node pos %s", - t, kd_data[idx]) + logger.debug("new constraint (frame %s) node pos %s (node %s)", + t, kd_data[idx], node) for nn_id in nn_nodes[idx]: if nn_id == idx: continue @@ -777,7 +780,13 @@ def _add_node_density_constraints_objective(self): constraint.set_coefficient(self.node_selected[nn], 1) if self.write_struct_svm: cnstr += "1*{} ".format(self.node_selected[nn]) - logger.debug(" neighbor pos %s %s", kd_data[nn_id], np.linalg.norm(np.array(kd_data[idx])-np.array(kd_data[nn_id]), ord=np.inf)) + logger.debug( + "neighbor pos %s %s (node %s)", + kd_data[nn_id], + np.linalg.norm(np.array(kd_data[idx]) - + np.array(kd_data[nn_id]), + ), + nn) constraint.set_coefficient(self.node_selected[node], 1) constraint.set_relation(pylp.Relation.LessEqual) constraint.set_value(1) From 4f2e3d0bf1ad9621b7cf04b67d3e1324b2e8336e Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 16:51:00 +0100 Subject: [PATCH 154/263] track: assert that all (or none) parameter sets have cell cycle key set --- linajea/tracking/non_minimal_solver.py | 2 -- linajea/tracking/non_minimal_track.py | 9 +++++++++ linajea/tracking/track.py | 10 ++++++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/linajea/tracking/non_minimal_solver.py b/linajea/tracking/non_minimal_solver.py index f7e79aa..dd35a8a 100644 --- a/linajea/tracking/non_minimal_solver.py +++ b/linajea/tracking/non_minimal_solver.py @@ -53,8 +53,6 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, self.update_objective(parameters, selected_key) def update_objective(self, parameters, selected_key): - assert self.parameters.use_cell_state == parameters.use_cell_state, \ - "cannot switch between w/ and w/o cell cycle within one run" self.parameters = parameters self.selected_key = selected_key diff --git a/linajea/tracking/non_minimal_track.py b/linajea/tracking/non_minimal_track.py index b2f4a41..09520d9 100644 --- a/linajea/tracking/non_minimal_track.py +++ b/linajea/tracking/non_minimal_track.py @@ -46,6 +46,15 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): if graph.number_of_nodes() == 0: return + use_cell_state = [p.use_cell_state + "mother" + if p.use_cell_state is not None + else None + for p in config.solve.parameters] + if any(use_cell_state): + assert None not in use_cell_state, + ("mixture of with and without use_cell_state in concurrent " + "solving not supported yet") + parameters = config.solve.parameters if not isinstance(selected_key, list): selected_key = [selected_key] diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 1d37f25..cb22329 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -53,15 +53,17 @@ def track(graph, config, selected_key, frame_key='t', frames=None, else None for p in config.solve.parameters] if any(cell_cycle_keys): + assert None not in cell_cycle_keys, \ + ("mixture of with and without cell_cycle key in concurrent " + "solving not supported yet") # remove nodes that don't have a cell cycle key, with warning to_remove = [] for node, data in graph.nodes(data=True): for key in cell_cycle_keys: if key not in data: - logger.warning("Node %d does not have cell cycle key %s", - node, key) - to_remove.append(node) - break + raise RuntimeError( + "Node %d does not have cell cycle key %s", + node, key) for node in to_remove: logger.debug("Removing node %d", node) From 7fc5fa9ac48cfb632f7d00b1c257d46ea4da7c29 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 16:51:24 +0100 Subject: [PATCH 155/263] non-min-solver: fix costs --- linajea/tracking/non_minimal_solver.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/linajea/tracking/non_minimal_solver.py b/linajea/tracking/non_minimal_solver.py index dd35a8a..524c3af 100644 --- a/linajea/tracking/non_minimal_solver.py +++ b/linajea/tracking/non_minimal_solver.py @@ -240,10 +240,9 @@ def _child_costs(self, node): return 0 elif self.parameters.use_cell_state == 'v1' or \ self.parameters.use_cell_state == 'v2': - # TODO cost_split -> cost_daughter return ((self.parameters.threshold_split_score - self.graph.nodes[node][self.parameters.prefix+'daughter']) * - self.parameters.cost_split) + self.parameters.cost_daughter) elif self.parameters.use_cell_state == 'v3' or \ self.parameters.use_cell_state == 'v4': if self.graph.nodes[node][self.parameters.prefix+'daughter'] > \ @@ -260,10 +259,9 @@ def _continuation_costs(self, node): self.parameters.use_cell_state == 'v3': return 0 elif self.parameters.use_cell_state == 'v2': - # TODO cost_split -> cost_normal return ((self.parameters.threshold_is_normal_score - self.graph.nodes[node][self.parameters.prefix+'normal']) * - self.parameters.cost_split) + self.parameters.cost_normal) elif self.parameters.use_cell_state == 'v4': if self.graph.nodes[node][self.parameters.prefix+'normal'] > \ self.parameters.threshold_is_normal_score: From 722e6332839ffe9ab45e35603247f1b7a47ff210 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 16:52:22 +0100 Subject: [PATCH 156/263] solve: write done to db for empty rois --- linajea/process_blockwise/solve_blockwise.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index 9adc1a5..ca5d36f 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -156,6 +156,11 @@ def solve_in_block(linajea_config, logger.debug("Write roi: %s", str(write_roi)) + if write_roi.empty(): + logger.info("Write roi empty, skipping block %d", block.block_id) + write_done(block, step_name, db_name, db_host) + return 0 + graph_provider = CandidateDatabase( db_name, db_host, From 20644168b233d783c6d49d00d93bcd49e85218e4 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 16:55:53 +0100 Subject: [PATCH 157/263] predict: change zarr compression, fix zarr roi --- .../process_blockwise/predict_blockwise.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index eab6b2d..b556133 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -3,7 +3,9 @@ import logging import os import time + import numpy as np +from numcodecs import Blosc import daisy from funlib.run import run @@ -94,31 +96,38 @@ def predict_blockwise( cell_indicator_ds = 'volumes/cell_indicator' maxima_ds = 'volumes/maxima' output_path = os.path.join(setup_dir, output_zarr) - logger.debug("Preparing zarr at %s" % output_path) + logger.info("Preparing zarr at %s" % output_path) + compressor = Blosc(cname='zstd', clevel=3, shuffle=Blosc.BITSHUFFLE) + file_roi = daisy.Roi(offset=data.datafile.file_roi.offset, + shape=data.datafile.file_roi.shape) + daisy.prepare_ds( output_path, parent_vectors_ds, - output_roi, + file_roi, voxel_size, dtype=np.float32, write_size=net_output_size, - num_channels=3) + num_channels=3, + compressor_object=compressor) daisy.prepare_ds( output_path, cell_indicator_ds, - output_roi, + file_roi, voxel_size, dtype=np.float32, write_size=net_output_size, - num_channels=1) + num_channels=1, + compressor_object=compressor) daisy.prepare_ds( output_path, maxima_ds, - output_roi, + file_roi, voxel_size, dtype=np.float32, write_size=net_output_size, - num_channels=1) + num_channels=1, + compressor_object=compressor) logger.info("Following ROIs in world units:") logger.info("Input ROI = %s", input_roi) From 063a6e259d051be29105bff6ff8172811776a8c1 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 16:59:09 +0100 Subject: [PATCH 158/263] write_cells: skip cells outside of mask/range --- linajea/gunpowder/write_cells.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/linajea/gunpowder/write_cells.py b/linajea/gunpowder/write_cells.py index 5cc8e5e..01deb01 100644 --- a/linajea/gunpowder/write_cells.py +++ b/linajea/gunpowder/write_cells.py @@ -19,11 +19,15 @@ def __init__( db_host, db_name, edge_length=1, + mask=None, + z_range=None, volume_shape=None): '''Edge length indicates the length of the edge of the cube from which parent vectors will be read. The cube will be centered around the maxima, and predictions within the cube of voxels will be averaged to get the parent vector to store in the db + If (binary) mask or z_range is provided, only cells within mask + or range will be written ''' self.maxima = maxima @@ -35,6 +39,8 @@ def __init__( self.client = None assert edge_length % 2 == 1, "Edge length should be odd" self.edge_length = edge_length + self.mask = mask + self.z_range = z_range self.volume_shape = volume_shape def process(self, batch, request): @@ -84,6 +90,18 @@ def process(self, batch, request): gp.Coordinate(self.volume_shape) * voxel_size)): continue + if self.mask is not None: + tmp_pos = position // voxel_size + if self.mask[tmp_pos[-self.mask.ndim:]] == 0: + logger.info("skipping cell mask {}".format(tmp_pos)) + continue + if self.z_range is not None: + tmp_pos = position // voxel_size + if tmp_pos[1] < self.z_range[0] or \ + tmp_pos[1] > self.z_range[1]: + logger.info("skipping cell zrange {}".format(tmp_pos)) + continue + cell_id = int(math.cantor_number( roi.get_begin()/voxel_size + index)) From 6864d7e1bc9978fb22e8597c7d8ba25eee3b9799 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 17:01:02 +0100 Subject: [PATCH 159/263] tracks source: extend use_radius parsing for backwards compatibility --- linajea/gunpowder/tracks_source.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/linajea/gunpowder/tracks_source.py b/linajea/gunpowder/tracks_source.py index 9e0f6c0..cdb4b13 100644 --- a/linajea/gunpowder/tracks_source.py +++ b/linajea/gunpowder/tracks_source.py @@ -109,7 +109,7 @@ def provide(self, request): "CSV points source got request for %s", request[self.points].roi) - point_filter = np.ones((self.locations.shape[0],), dtype=np.bool) + point_filter = np.ones((self.locations.shape[0],), dtype=bool) for d in range(self.locations.shape[1]): point_filter = np.logical_and(point_filter, self.locations[:, d] >= min_bb[d]) @@ -138,11 +138,23 @@ def _get_points(self, point_filter): filtered_track_info): t = location[0] if isinstance(self.use_radius, dict): - for th in sorted(self.use_radius.keys()): - if t < int(th): - value = track_info[3] - value[0] = self.use_radius[th] - break + if len(self.use_radius.keys()) > 1: + value = None + for th in sorted(self.use_radius.keys()): + if t < int(th): + value = track_info[3] + + try: + value[0] = self.use_radius[th] + except TypeError as e: + print(value, self.use_radius, track_info, + self.filename) + raise e + break + assert value is not None, "verify use_radius in config" + else: + value = (track_info[3] if list(self.use_radius.values())[0] + else None) else: value = track_info[3] if self.use_radius else None node = TrackNode( From a1bc1b801e52127ad9b615c5ae278f59e4491a3a Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 17:02:12 +0100 Subject: [PATCH 160/263] normalizeMinMax: add interpolatable arg --- linajea/gunpowder/normalize.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/linajea/gunpowder/normalize.py b/linajea/gunpowder/normalize.py index 4c6401d..e8d1f44 100644 --- a/linajea/gunpowder/normalize.py +++ b/linajea/gunpowder/normalize.py @@ -35,9 +35,12 @@ def __init__( array, mn, mx, + interpolatable=True, dtype=np.float32, clip=False): + super(NormalizeMinMax, self).__init__(array, interpolatable=interpolatable) + self.array = array self.mn = mn self.mx = mx From b32f89d738bb727825c7ad381c94bbe9d0678f8f Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 17:07:10 +0100 Subject: [PATCH 161/263] config: cleanup init --- linajea/config/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index 4e476ea..4204edf 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -11,19 +11,14 @@ from .extract import ExtractConfig from .general import GeneralConfig from .job import JobConfig -# from .linajea_config import LinajeaConfig from .predict import (PredictCellCycleConfig, PredictTrackingConfig) from .solve import (SolveConfig, SolveParametersMinimalConfig, SolveParametersNonMinimalConfig) from .tracking_config import TrackingConfig -# from .test import (TestTrackingConfig, -# TestCellCycleConfig) from .train import (TrainTrackingConfig, TrainCellCycleConfig) -# from .validate import (ValidateConfig, -# ValidateCellCycleConfig) from .train_test_validate_data import (InferenceDataTrackingConfig, InferenceDataCellCycleConfig) From 2787cf62d6042a966e92f560de90f52f51b90856 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 17:32:26 +0100 Subject: [PATCH 162/263] check for off-by-one false divisions compute local (+-1) graph structure around false division contract potential chains compare to gt structure isomorphic? if yes there is a division at the same spatial location but one frame earlier or later As the frame rate is limited and annotations can be imprecise when exactly a division happened it might be reasonable to do not count such errors (this code just detects them, how they are handled is decided elsewhere) --- linajea/evaluation/evaluator.py | 257 ++++++++++++++++++++++++++++---- 1 file changed, 232 insertions(+), 25 deletions(-) diff --git a/linajea/evaluation/evaluator.py b/linajea/evaluation/evaluator.py index ef6921f..113c7df 100644 --- a/linajea/evaluation/evaluator.py +++ b/linajea/evaluation/evaluator.py @@ -1,9 +1,11 @@ from copy import deepcopy +import itertools import logging import math import networkx as nx from .report import Report from .validation_metric import validation_score +from .analyze_candidates import get_node_recall, get_edge_recall logger = logging.getLogger(__name__) @@ -104,6 +106,14 @@ def evaluate(self): self.get_perfect_segments(self.window_size) if self.validation_score: self.get_validation_score() + self.get_div_topology_stats() + + num_matches, num_gt_nodes = get_node_recall( + self.rec_track_graph, self.gt_track_graph, 15) + self.report.node_recall = num_matches / num_gt_nodes + num_matches, num_gt_edges = get_edge_recall( + self.rec_track_graph, self.gt_track_graph, 15, 35) + self.report.edge_recall = num_matches / num_gt_edges return self.report def get_fp_edges(self): @@ -112,9 +122,17 @@ def get_fp_edges(self): If dense, this is the total number of unmatched rec edges. ''' if self.sparse: - fp_edges = self.unselected_potential_matches + num_fp_edges = self.unselected_potential_matches else: - fp_edges = self.report.rec_edges - self.report.matched_edges + num_fp_edges = self.report.rec_edges - self.report.matched_edges + + matched_edges = set([match[1] for match in self.edge_matches]) + rec_edges = set(self.rec_track_graph.edges) + fp_edges = list(rec_edges - matched_edges) + assert len(fp_edges) == num_fp_edges, "List of fp edges "\ + "has %d edges, but calculated %d fp edges"\ + % (len(fp_edges), num_fp_edges) + self.report.set_fp_edges(fp_edges) def get_fn_edges(self): @@ -178,7 +196,7 @@ def get_fp_divisions(self): previous match is the end of a gt track. ''' - fp_div_nodes = [] + self.fp_div_nodes = [] for rec_parent in self.rec_parents: next_edges = self.rec_track_graph.next_edges(rec_parent) assert len(next_edges) == 2,\ @@ -212,13 +230,18 @@ def get_fp_divisions(self): # and will be taken care of below if not self.__div_match_node_equality(c1_t, c2_t): - logger.debug("FP division at rec node %d" % rec_parent) - fp_div_nodes.append(rec_parent) + node = [self.rec_track_graph.nodes[rec_parent]['t'], + self.rec_track_graph.nodes[rec_parent]['z'], + self.rec_track_graph.nodes[rec_parent]['y'], + self.rec_track_graph.nodes[rec_parent]['x']] + logger.debug("FP division at rec node %d %s %s" % ( + rec_parent, node, next_edge_matches)) + self.fp_div_nodes.append(rec_parent) if c1_t is not None: self.gt_track_graph.nodes[c1_t]['FP_D'] = True if c2_t is not None: self.gt_track_graph.nodes[c2_t]['FP_D'] = True - self.report.set_fp_divisions(fp_div_nodes) + self.report.set_fp_divisions(self.fp_div_nodes) def __div_match_node_equality(self, n1, n2): if n1 is None or n2 is None: @@ -257,10 +280,10 @@ def get_fn_divisions(self): c1_t and c2_t. If c1_t = c2_t, no error. If c1_t != c2_t, no connections. ''' - fn_div_no_connection_nodes = [] - fn_div_unconnected_child_nodes = [] - fn_div_unconnected_parent_nodes = [] - tp_div_nodes = [] + self.fn_div_no_connection_nodes = [] + self.fn_div_unconnected_child_nodes = [] + self.fn_div_unconnected_parent_nodes = [] + self.tp_div_nodes = [] for gt_parent in self.gt_parents: next_edges = self.gt_track_graph.next_edges(gt_parent) @@ -288,12 +311,12 @@ def get_fn_divisions(self): if self.__div_match_node_equality(c1_t, c2_t): # TP - children are connected logger.debug("TP division - no gt parent") - tp_div_nodes.append(gt_parent) + self.tp_div_nodes.append(gt_parent) continue else: # FN - No connections logger.debug("FN - no connections") - fn_div_no_connection_nodes.append(gt_parent) + self.fn_div_no_connection_nodes.append(gt_parent) continue prev_edge = prev_edges[0] prev_edge_match = self.gt_edges_to_rec_edges.get(prev_edge) @@ -301,29 +324,38 @@ def get_fn_divisions(self): is not None else None logger.debug("prev_s: %s" % str(prev_s)) + is_tp_div = False if self.__div_match_node_equality(c1_t, c2_t): - if self.__div_match_node_equality(c1_t, prev_s): - # TP - logger.debug("TP div") - tp_div_nodes.append(gt_parent) - else: + # TP + logger.debug("TP div") + self.tp_div_nodes.append(gt_parent) + is_tp_div = True + if not self.__div_match_node_equality(c1_t, prev_s): # FN - Unconnected parent logger.debug("FN div - unconnected parent") - fn_div_unconnected_parent_nodes.append(gt_parent) + self.fn_div_unconnected_parent_nodes.append(gt_parent) else: if self.__div_match_node_equality(c1_t, prev_s)\ or self.__div_match_node_equality(c2_t, prev_s): # FN - one unconnected child logger.debug("FN div - one unconnected child") - fn_div_unconnected_child_nodes.append(gt_parent) + self.fn_div_unconnected_child_nodes.append(gt_parent) else: # FN - no connections logger.debug("FN div - no connections") - fn_div_no_connection_nodes.append(gt_parent) - self.report.set_fn_divisions(fn_div_no_connection_nodes, - fn_div_unconnected_child_nodes, - fn_div_unconnected_parent_nodes, - tp_div_nodes) + self.fn_div_no_connection_nodes.append(gt_parent) + + if not is_tp_div: + node = [self.gt_track_graph.nodes[gt_parent]['t'], + self.gt_track_graph.nodes[gt_parent]['z'], + self.gt_track_graph.nodes[gt_parent]['y'], + self.gt_track_graph.nodes[gt_parent]['x']] + logger.debug("FN division at gt node %d %s %s" % ( + gt_parent, node, next_edge_matches)) + self.report.set_fn_divisions(self.fn_div_no_connection_nodes, + self.fn_div_unconnected_child_nodes, + self.fn_div_unconnected_parent_nodes, + self.tp_div_nodes) def get_f_score(self): self.report.set_f_score() @@ -397,7 +429,7 @@ def get_perfect_segments(self, window_size): for current_node in current_nodes: if current_node != start_node: if 'IS' in self.gt_track_graph.nodes[current_node] or\ - 'FP_D' in self.gt_track_graph.nodes[current_node]: + 'FP_D' in self.gt_track_graph.nodes[current_node]: correct = False for next_edge in next_edges: if 'FN' in self.gt_track_graph.get_edge_data(*next_edge): @@ -457,3 +489,178 @@ def get_validation_score(self): deepcopy(self.gt_track_graph), deepcopy(self.rec_track_graph)) self.report.set_validation_score(vald_score) + + def get_div_topology_stats(self): + self.iso_fn_div_nodes = [] + for fn_div_node in itertools.chain( + self.fn_div_no_connection_nodes, + self.fn_div_unconnected_child_nodes, + self.fn_div_unconnected_parent_nodes): + + gt_tmp_grph, rec_tmp_grph = self.get_local_graphs( + fn_div_node, + self.gt_track_graph, self.rec_track_graph) + + if len(gt_tmp_grph.nodes()) == 0 or len(rec_tmp_grph) == 0: + continue + if nx.is_isomorphic(gt_tmp_grph, rec_tmp_grph): + fp_div_node = None + for node, degree in rec_tmp_grph.degree(): + if degree == 3: + fp_div_node = node + logger.debug("found isomorphic fn division: %d/%s", + fp_div_node, fn_div_node) + self.iso_fn_div_nodes.append(fn_div_node) + else: + logger.debug("not-isomorphic fn division: %d", fn_div_node) + self.iso_fp_div_nodes = [] + for fp_div_node in self.fp_div_nodes: + fp_div_node = int(fp_div_node) + rec_tmp_grph, gt_tmp_grph = self.get_local_graphs( + fp_div_node, + self.rec_track_graph, self.gt_track_graph, rec_to_gt=True) + if len(gt_tmp_grph.nodes()) == 0 or len(rec_tmp_grph) == 0: + continue + if nx.is_isomorphic(gt_tmp_grph, rec_tmp_grph): + fn_div_node = None + for node, degree in gt_tmp_grph.degree(): + if degree == 3: + fn_div_node = node + logger.debug("found isomorphic fp division: %d/%s", + fp_div_node, fn_div_node) + self.iso_fp_div_nodes.append(fp_div_node) + else: + logger.debug("not-isomorphic fp division: %d", fp_div_node) + + self.report.set_iso_fn_divisions(self.iso_fn_div_nodes) + self.report.set_iso_fp_divisions(self.iso_fp_div_nodes) + + def get_local_graphs(self, div_node, g1, g2, rec_to_gt=False): + + g1_nodes = [] + try: + for n1 in g1.successors(div_node): + g1_nodes.append(n1) + for n2 in g1.successors(n1): + g1_nodes.append(n2) + for n2 in g1.predecessors(n1): + g1_nodes.append(n2) + for n1 in g1.predecessors(div_node): + g1_nodes.append(n1) + for n2 in g1.successors(n1): + g1_nodes.append(n2) + for n2 in g1.predecessors(n1): + g1_nodes.append(n2) + except: + raise RuntimeError("Overlooked edge case in get_local_graph?") + + prev_edge = list(g1.prev_edges(div_node)) + prev_edge = prev_edge[0] if len(prev_edge) > 0 else None + next_edges = list(g1.next_edges(div_node)) + prev_edge_match = None + next_edge_match = None + if not rec_to_gt: + if prev_edge is not None: + prev_edge_match = self.gt_edges_to_rec_edges.get(prev_edge) + if prev_edge_match is None: + for next_edge in next_edges: + next_edge_match = self.gt_edges_to_rec_edges.get(next_edge) + if next_edge_match is not None: + break + else: + if prev_edge is not None: + prev_edge_match = self.rec_edges_to_gt_edges.get(prev_edge) + if prev_edge_match is None: + for next_edge in next_edges: + next_edge_match = self.rec_edges_to_gt_edges.get(next_edge) + if next_edge_match is not None: + break + + g2_nodes = [] + if prev_edge_match is not None or next_edge_match is not None: + if prev_edge_match is not None: + div_node_match = prev_edge_match[0] + else: + div_node_match = next_edge_match[0] + + for n1 in g2.successors(div_node_match): + g2_nodes.append(n1) + for n2 in g2.successors(n1): + g2_nodes.append(n2) + for n2 in g2.predecessors(n1): + g2_nodes.append(n2) + for n1 in g2.predecessors(div_node_match): + g2_nodes.append(n1) + for n2 in g2.successors(n1): + g2_nodes.append(n2) + for n2 in g2.predecessors(n1): + g2_nodes.append(n2) + + g1_tmp_grph = g1.subgraph(g1_nodes).to_undirected() + g2_tmp_grph = g2.subgraph(g2_nodes).to_undirected() + + if len(g1_tmp_grph.nodes()) != 0 and len(g2_tmp_grph.nodes()) != 0: + g1_tmp_grph = contract(g1_tmp_grph) + g2_tmp_grph = contract(g2_tmp_grph) + return g1_tmp_grph, g2_tmp_grph + + +def contract(g): + """ + Contract chains of neighbouring vertices with degree 2 into one hypernode. + Arguments: + ---------- + g -- networkx.Graph instance + Returns: + -------- + h -- networkx.Graph instance + the contracted graph + hypernode_to_nodes -- dict: int hypernode -> [v1, v2, ..., vn] + dictionary mapping hypernodes to nodes + TODO: cite source + """ + + # create subgraph of all nodes with degree 2 + is_chain = [node for node, degree in g.degree() if degree <= 2] + chains = g.subgraph(is_chain) + + # contract connected components (which should be chains of variable length) + # into single node + components = [chains.subgraph(c).copy() + for c in nx.connected_components(chains)] + hypernode = max(g.nodes()) + 1 + hypernodes = [] + hyperedges = [] + hypernode_to_nodes = dict() + false_alarms = [] + for component in components: + if component.number_of_nodes() > 1: + + hypernodes.append(hypernode) + vs = [node for node in component.nodes()] + hypernode_to_nodes[hypernode] = vs + + # create new edges from the neighbours of the chain ends to the + # hypernode + component_edges = [e for e in component.edges()] + for v, w in [e for e in g.edges(vs) + if not ((e in component_edges) or + (e[::-1] in component_edges))]: + if v in component: + hyperedges.append([hypernode, w]) + else: + hyperedges.append([v, hypernode]) + + hypernode += 1 + + # nothing to collapse as there is only a single node in component: + else: + false_alarms.extend([node for node in component.nodes()]) + + # initialise new graph with all other nodes + not_chain = [node for node in g.nodes() if node not in is_chain] + h = g.subgraph(not_chain + false_alarms).copy() + h.add_nodes_from(hypernodes) + h.add_edges_from(hyperedges) + + return h From fbb99755b183fc33c879800242096e41bc1e639b Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 17:45:55 +0100 Subject: [PATCH 163/263] eval match: for debugging, analyze avg dist of matches --- linajea/evaluation/match.py | 87 +++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/linajea/evaluation/match.py b/linajea/evaluation/match.py index 9d9f491..83e2f2f 100644 --- a/linajea/evaluation/match.py +++ b/linajea/evaluation/match.py @@ -43,6 +43,9 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): edge_matches = [] edge_fps = 0 + avg_dist = [] + avg_dist_target = [] + avg_dist_source = [] for t in range(begin, end): node_pairs_xy = {} frame_nodes_x = [] @@ -50,6 +53,10 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): positions_x = [] positions_y = [] + avg_dist_frame = [] + avg_dist_target_frame = [] + avg_dist_source_frame = [] + # get all nodes and their positions in x and y of the current frame frame_nodes_x = track_graph_x.cells_by_frame(t) @@ -111,6 +118,86 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): logger.debug( "Done matching frame %d, found %d matches and %d edge fps", t, len(edge_matches_in_frame), edge_fps_in_frame) + + for exid, eyid in edge_matches_in_frame: + node_xid_source = edges_x[exid][0] + node_xid_target = edges_x[exid][1] + node_yid_source = edges_y[eyid][0] + node_yid_target = edges_y[eyid][1] + + pos_x_target = np.array( + [track_graph_x.nodes[node_xid_target]['z'], + track_graph_x.nodes[node_xid_target]['y'], + track_graph_x.nodes[node_xid_target]['x']]) + pos_y_target = np.array( + [track_graph_y.nodes[node_yid_target]['z'], + track_graph_y.nodes[node_yid_target]['y'], + track_graph_y.nodes[node_yid_target]['x']]) + distance_target = np.linalg.norm(pos_x_target - pos_y_target) + pos_x_source = np.array( + [track_graph_x.nodes[node_xid_source]['z'], + track_graph_x.nodes[node_xid_source]['y'], + track_graph_x.nodes[node_xid_source]['x']]) + pos_y_source = np.array( + [track_graph_y.nodes[node_yid_source]['z'], + track_graph_y.nodes[node_yid_source]['y'], + track_graph_y.nodes[node_yid_source]['x']]) + distance_source = np.linalg.norm(pos_x_source - pos_y_source) + + avg_dist_source_frame.append(distance_source) + avg_dist_source.append(distance_source) + avg_dist_target_frame.append(distance_target) + avg_dist_target.append(distance_target) + avg_dist_frame.append(distance_target) + avg_dist_frame.append(distance_source) + avg_dist.append(distance_target) + avg_dist.append(distance_source) + if distance_target >= 7.0: + logger.debug("target %d %d %.3f %s %s", node_xid_target, + node_yid_target, distance_target, + pos_x_target, pos_y_target) + if distance_source >= 7.0: + logger.debug("source %d %d %.3f %s %s", node_xid_source, + node_yid_source, distance_source, + pos_x_source, pos_y_source) + # logger.info("%.3f %.3f", distance_target, distance_source) + + logger.debug("frame %d, count matches %d", + t, len(avg_dist_source_frame)) + if len(avg_dist_source_frame) == 0: + continue + logger.debug("dist source: avg %.3f, med %.3f, min %.3f, max %.3f", + np.mean(avg_dist_source_frame), + np.median(avg_dist_source_frame), + np.min(avg_dist_source_frame), + np.max(avg_dist_source_frame)) + logger.debug("dist target: avg %.3f, med %.3f, min %.3f, max %.3f", + np.mean(avg_dist_target_frame), + np.median(avg_dist_target_frame), + np.min(avg_dist_target_frame), + np.max(avg_dist_target_frame)) + logger.debug("dist : avg %.3f, med %.3f, min %.3f, max %.3f", + np.mean(avg_dist_frame), + np.median(avg_dist_frame), + np.min(avg_dist_frame), + np.max(avg_dist_frame)) + + logger.debug("total count matches %d", len(avg_dist_source)) + logger.debug("dist source: avg %.3f, med %.3f, min %.3f, max %.3f", + np.mean(avg_dist_source), + np.median(avg_dist_source), + np.min(avg_dist_source), + np.max(avg_dist_source)) + logger.debug("dist target: avg %.3f, med %.3f, min %.3f, max %.3f", + np.mean(avg_dist_target), + np.median(avg_dist_target), + np.min(avg_dist_target), + np.max(avg_dist_target)) + logger.debug("dist : avg %.3f, med %.3f, min %.3f, max %.3f", + np.mean(avg_dist), + np.median(avg_dist), + np.min(avg_dist), + np.max(avg_dist)) logger.info("Done matching, found %d matches and %d edge fps" % (len(edge_matches), edge_fps)) return edges_x, edges_y, edge_matches, edge_fps From 09925fdb71d1a84db7f1c04c6b58297af6ebe1ad Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 18:30:34 +0100 Subject: [PATCH 164/263] report isomorphic fn/fp divisions add flags to toggle counting iso fp/fn divs (ignore_one_off_div_errors) and fn divs where just the parent of the mother cell is missing (fn_div_count_unconnected_parent) --- linajea/config/evaluate.py | 2 + linajea/evaluation/evaluate.py | 8 ++- linajea/evaluation/evaluate_setup.py | 14 +++- linajea/evaluation/evaluator.py | 8 ++- linajea/evaluation/report.py | 97 ++++++++++++++++++++++++++-- 5 files changed, 117 insertions(+), 12 deletions(-) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 3d83631..68751db 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -15,6 +15,8 @@ class EvaluateParametersConfig: window_size = attr.ib(type=int, default=50) filter_polar_bodies = attr.ib(type=bool, default=None) filter_polar_bodies_key = attr.ib(type=str, default=None) + ignore_one_off_div_errors = attr.ib(type=bool, default=False) + fn_div_count_unconnected_parent = attr.ib(type=bool, default=True) # deprecated frames = attr.ib(type=List[int], default=None) # deprecated diff --git a/linajea/evaluation/evaluate.py b/linajea/evaluation/evaluate.py index 58a2dfe..08bb515 100644 --- a/linajea/evaluation/evaluate.py +++ b/linajea/evaluation/evaluate.py @@ -11,7 +11,9 @@ def evaluate( matching_threshold, sparse, validation_score=False, - window_size=50): + window_size=50, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True): ''' Performs both matching and evaluation on the given gt and reconstructed tracks, and returns a Report with the results. @@ -34,5 +36,7 @@ def evaluate( unselected_potential_matches, sparse=sparse, validation_score=validation_score, - window_size=window_size) + window_size=window_size, + ignore_one_off_div_errors=ignore_one_off_div_errors, + fn_div_count_unconnected_parent=fn_div_count_unconnected_parent) return evaluator.evaluate() diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index 93512d5..dab40ab 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -146,13 +146,21 @@ def evaluate_setup(linajea_config): gt_subgraph, frame_key='t', roi=gt_subgraph.roi) logger.info("Matching edges for parameters with id %d" % parameters_id) + matching_threshold = linajea_config.evaluate.parameters.matching_threshold + validation_score = linajea_config.evaluate.parameters.validation_score + ignore_one_off_div_errors = \ + linajea_config.evaluate.parameters.ignore_one_off_div_errors + fn_div_count_unconnected_parent = \ + linajea_config.evaluate.parameters.fn_div_count_unconnected_parent report = evaluate( gt_track_graph, track_graph, - matching_threshold=linajea_config.evaluate.parameters.matching_threshold, + matching_threshold=matching_threshold, sparse=linajea_config.general.sparse, - validation_score=linajea_config.evaluate.parameters.validation_score, - window_size=linajea_config.evaluate.parameters.window_size) + validation_score=validation_score, + window_size=linajea_config.evaluate.parameters.window_size, + ignore_one_off_div_errors=ignore_one_off_div_errors, + fn_div_count_unconnected_parent=fn_div_count_unconnected_parent) logger.info("Done evaluating results for %d. Saving results to mongo." % parameters_id) diff --git a/linajea/evaluation/evaluator.py b/linajea/evaluation/evaluator.py index 113c7df..3707de3 100644 --- a/linajea/evaluation/evaluator.py +++ b/linajea/evaluation/evaluator.py @@ -46,8 +46,12 @@ def __init__( sparse=True, validation_score=False, window_size=50, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True ): self.report = Report() + self.report.fn_div_count_unconnected_parent = \ + fn_div_count_unconnected_parent self.gt_track_graph = gt_track_graph self.rec_track_graph = rec_track_graph @@ -56,6 +60,7 @@ def __init__( self.sparse = sparse self.validation_score = validation_score self.window_size = window_size + self.ignore_one_off_div_errors = ignore_one_off_div_errors # get tracks self.gt_tracks = gt_track_graph.get_tracks() @@ -106,7 +111,8 @@ def evaluate(self): self.get_perfect_segments(self.window_size) if self.validation_score: self.get_validation_score() - self.get_div_topology_stats() + if self.ignore_one_off_div_errors: + self.get_div_topology_stats() num_matches, num_gt_nodes = get_node_recall( self.rec_track_graph, self.gt_track_graph, 15) diff --git a/linajea/evaluation/report.py b/linajea/evaluation/report.py index 2702dd3..71599ed 100644 --- a/linajea/evaluation/report.py +++ b/linajea/evaluation/report.py @@ -1,4 +1,8 @@ from copy import deepcopy +import logging + +logger = logging.getLogger(__name__) + class Report: def __init__(self): @@ -20,7 +24,9 @@ def __init__(self): self.fn_edges = None self.identity_switches = None self.fp_divisions = None + self.iso_fp_division = None self.fn_divisions = None + self.iso_fn_division = None self.fn_divs_no_connections = None self.fn_divs_unconnected_child = None self.fn_divs_unconnected_parent = None @@ -43,6 +49,8 @@ def __init__(self): self.unconnected_parent_gt_nodes = None self.tp_div_gt_nodes = None + self.fn_div_count_unconnected_parent = False + def set_track_stats( self, gt_tracks, @@ -113,8 +121,9 @@ def set_fn_edges(self, fn_edges): self.fn_edges = len(fn_edges) self.fn_edge_list = [(int(s), int(t)) for s, t in fn_edges] - def set_fp_edges(self, num_fp_edges): - self.fp_edges = num_fp_edges + def set_fp_edges(self, fp_edges): + self.fp_edges = len(fp_edges) + self.fp_edge_list = [(int(s), int(t)) for s, t in fp_edges] def set_identity_switches(self, identity_switches): ''' @@ -144,9 +153,10 @@ def set_fn_divisions( self.fn_divs_no_connections = len(fn_divs_no_connections) self.fn_divs_unconnected_child = len(fn_divs_unconnected_child) self.fn_divs_unconnected_parent = len(fn_divs_unconnected_parent) - self.fn_divisions = self.fn_divs_no_connections +\ - self.fn_divs_unconnected_child +\ - self.fn_divs_unconnected_parent + self.fn_divisions = self.fn_divs_no_connections + \ + self.fn_divs_unconnected_child + if self.fn_div_count_unconnected_parent: + self.fn_divisions += self.fn_divs_unconnected_parent self.no_connection_gt_nodes = [int(n) for n in fn_divs_no_connections] self.unconnected_child_gt_nodes = [ @@ -188,13 +198,88 @@ def set_aeftl_and_erl(self, aeftl, erl): def set_validation_score(self, validation_score): self.validation_score = validation_score + def set_iso_fn_divisions(self, iso_fn_div_nodes): + self.iso_fn_division = len(iso_fn_div_nodes) + fn_div_gt_nodes = (self.no_connection_gt_nodes + + self.unconnected_child_gt_nodes) + if self.fn_div_count_unconnected_parent: + fn_div_gt_nodes += self.unconnected_parent_gt_nodes + fn_div_gt_nodes = [f for f in fn_div_gt_nodes + if f not in iso_fn_div_nodes] + self.fn_divisions = len(fn_div_gt_nodes) + + # remove fp/fn edges directly involved in iso fn div + logger.debug("fn edges before iso_fn_div: %d", self.fn_edges) + logger.debug("fp edges before iso_fn_div: %d", self.fp_edges) + edges = self.fn_edge_list + self.fn_edge_list = [] + for u, v in edges: + found = False + for d in iso_fn_div_nodes: + if int(d) == u or int(d) == v: + found = True + if not found: + self.fn_edge_list.append((u, v)) + self.fn_edge_list = list(self.fn_edge_list) + self.fn_edges = len(self.fn_edge_list) + + edges = self.fp_edge_list + self.fp_edge_list = [] + for u, v in edges: + found = False + for d in iso_fn_div_nodes: + if int(d) == u or int(d) == v: + found = True + if not found: + self.fp_edge_list.append((u, v)) + self.fp_edge_list = list(self.fp_edge_list) + self.fp_edges = len(self.fp_edge_list) + logger.debug("fn edges after iso_fn_div: %d", self.fn_edges) + logger.debug("fp edges after iso_fn_div: %d", self.fp_edges) + + def set_iso_fp_divisions(self, iso_fp_div_nodes): + self.iso_fp_division = len(iso_fp_div_nodes) + self.fp_div_rec_nodes = [f for f in self.fp_div_rec_nodes + if f not in iso_fp_div_nodes] + self.fp_divisions = len(self.fp_div_rec_nodes) + + # remove fp/fn edges directly involved in iso fp div + logger.debug("fp edges before iso_fp_div: %d", self.fp_edges) + logger.debug("fn edges before iso_fp_div: %d", self.fn_edges) + edges = self.fp_edge_list + self.fp_edge_list = [] + for u, v in edges: + found = False + for d in iso_fp_div_nodes: + if int(d) == u or int(d) == v: + found = True + if not found: + self.fp_edge_list.append((u, v)) + self.fp_edge_list = list(self.fp_edge_list) + self.fp_edges = len(self.fp_edge_list) + + edges = self.fn_edge_list + self.fn_edge_list = [] + for u, v in edges: + found = False + for d in iso_fp_div_nodes: + if int(d) == u or int(d) == v: + found = True + if not found: + self.fn_edge_list.append((u, v)) + self.fn_edge_list = list(self.fn_edge_list) + self.fn_edges = len(self.fn_edge_list) + logger.debug("fp edges after iso_fp_div: %d", self.fp_edges) + logger.debug("fn edges after iso_fp_div: %d", self.fn_edges) + def get_report(self): return self.__dict__ def get_short_report(self): report = deepcopy(self.__dict__) - # STATISTICS + # STATISTICS del report['fn_edge_list'] + del report['fp_edge_list'] del report['identity_switch_gt_nodes'] del report['fp_div_rec_nodes'] del report['no_connection_gt_nodes'] From e21f21383412f1869713654ed1aa5996bdbfade5 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 18:36:00 +0100 Subject: [PATCH 165/263] eval: how many of cells in last fr are tracked fully until first fr --- linajea/evaluation/evaluator.py | 42 +++++++++++++++++++++++++++++++++ linajea/evaluation/report.py | 5 ++++ 2 files changed, 47 insertions(+) diff --git a/linajea/evaluation/evaluator.py b/linajea/evaluation/evaluator.py index 3707de3..8e6cb0e 100644 --- a/linajea/evaluation/evaluator.py +++ b/linajea/evaluation/evaluator.py @@ -113,6 +113,7 @@ def evaluate(self): self.get_validation_score() if self.ignore_one_off_div_errors: self.get_div_topology_stats() + self.get_error_free_tracks() num_matches, num_gt_nodes = get_node_recall( self.rec_track_graph, self.gt_track_graph, 15) @@ -610,6 +611,47 @@ def get_local_graphs(self, div_node, g1, g2, rec_to_gt=False): g2_tmp_grph = contract(g2_tmp_grph) return g1_tmp_grph, g2_tmp_grph + def get_error_free_tracks(self): + + roi = self.gt_track_graph.roi + start_frame = roi.get_offset()[0] + end_frame = start_frame + roi.get_shape()[0] + + rec_nodes_last_frame = self.rec_track_graph.cells_by_frame(end_frame-1) + + cnt_rec_nodes_last_frame = len(rec_nodes_last_frame) + cnt_gt_nodes_last_frame = len(self.gt_track_graph.cells_by_frame( + end_frame-1)) + cnt_error_free_tracks = cnt_rec_nodes_last_frame + + nodes_prev_frame = rec_nodes_last_frame + logger.info("track range %s %s", end_frame-1, start_frame) + for i in range(end_frame-1, start_frame, -1): + nodes_curr_frame = [] + logger.debug("nodes in frame %s: %s ", i, len(nodes_prev_frame)) + for n in nodes_prev_frame: + prev_edges = list(self.rec_track_graph.prev_edges(n)) + if len(prev_edges) == 0: + logger.debug("no predecessor") + cnt_error_free_tracks -= 1 + continue + else: + assert len(prev_edges) == 1, \ + "node can only have single predecessor!" + if prev_edges[0] in self.rec_edges_to_gt_edges: + nodes_curr_frame.append(prev_edges[0][1]) + else: + logger.debug("predecessor not matched") + cnt_error_free_tracks -=1 + nodes_prev_frame = list(set(nodes_curr_frame)) + + logger.info("error free tracks: %s/%s %s", + cnt_error_free_tracks, cnt_rec_nodes_last_frame, + cnt_error_free_tracks/cnt_gt_nodes_last_frame) + self.report.set_error_free_tracks(cnt_error_free_tracks, + cnt_rec_nodes_last_frame, + cnt_gt_nodes_last_frame) + def contract(g): """ diff --git a/linajea/evaluation/report.py b/linajea/evaluation/report.py index 71599ed..1e286cf 100644 --- a/linajea/evaluation/report.py +++ b/linajea/evaluation/report.py @@ -272,6 +272,11 @@ def set_iso_fp_divisions(self, iso_fp_div_nodes): logger.debug("fp edges after iso_fp_div: %d", self.fp_edges) logger.debug("fn edges after iso_fp_div: %d", self.fn_edges) + def set_error_free_tracks(self, cnt_error_free, cnt_total_rec, cnt_total_gt): + self.num_error_free_tracks = cnt_error_free + self.num_rec_cells_last_frame = cnt_total_rec + self.num_gt_cells_last_frame = cnt_total_gt + def get_report(self): return self.__dict__ From 9e2a660aa898536fb8b479f91335f2b8f9472124 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 11 Jan 2022 18:42:25 +0100 Subject: [PATCH 166/263] adjust selection of inference data inference data is selected based on config and runtime flags all checks (which dataset, roi etc) are performed at a single point by get_next_inference_data.py this way the various backend scripts can be simpler --- linajea/get_next_inference_data.py | 127 +++++++++++++++++------------ 1 file changed, 77 insertions(+), 50 deletions(-) diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py index 8743043..f64d969 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/get_next_inference_data.py @@ -28,13 +28,18 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): inference = deepcopy(config.test_data) checkpoints = [config.test_data.checkpoint] + if args.validate_on_train: + inference.data_sources = deepcopy(config.train_data.data_sources) + if args.checkpoint > 0: checkpoints = [args.checkpoint] - if is_solve and args.val_param_id is not None: - config = fix_solve_pid(args, config, checkpoints, inference) - if is_evaluate and args.param_id is not None: - config = fix_evaluate_pid(args, config, checkpoints, inference) + if (is_solve or is_evaluate) and args.val_param_id is not None: + config = fix_val_param_pid(args, config, checkpoints, inference) + if (is_solve or is_evaluate) and \ + (args.param_id is not None or + (hasattr(args, "param_ids") and args.param_ids is not None)): + config = fix_param_pid(args, config, checkpoints, inference) max_cell_move = max(config.extract.edge_move_threshold.values()) for pid in range(len(config.solve.parameters)): @@ -42,6 +47,15 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): config.solve.parameters[pid].max_cell_move = max_cell_move os.makedirs("tmp_configs", exist_ok=True) + if hasattr(args, "param_list_idx") and \ + args.param_list_idx is not None: + param_list_idx = int(args.param_list_idx[1:-1]) + assert param_list_idx <= len(config.solve.parameters), \ + ("invalid index into parameter set list of config, " + "too large ({}, {})").format( + param_list_idx, len(config.solve.parameters)) + solve_parameters = deepcopy(config.solve.parameters[param_list_idx-1]) + config.solve.parameters = [solve_parameters] solve_parameters_sets = deepcopy(config.solve.parameters) for checkpoint in checkpoints: inference_data = { @@ -63,11 +77,10 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): config = fix_solve_roi(config) if is_evaluate: - config = fix_evaluate_roi(config) for solve_parameters in solve_parameters_sets: solve_parameters = deepcopy(solve_parameters) - solve_parameters.roi = config.inference.data_source.roi config.solve.parameters = [solve_parameters] + config = fix_solve_roi(config) yield config continue @@ -78,64 +91,78 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): yield config -def fix_solve_pid(args, config, checkpoints, inference): - if args.param_id is not None: - assert len(checkpoints) == 1, "use param_id to reevaluate a single instance" - sample_name = inference.data_sources[0].datafile.filename, - pid = args.param_id +def fix_val_param_pid(args, config, checkpoints, inference): + if args.validation: + sample_name = config.test_data.data_sources[0].datafile.filename + threshold = config.test_data.cell_score_threshold else: - assert not args.validation, "use val_param_id to apply validation parameters with that ID to test set not validation set" sample_name = config.validate_data.data_sources[0].datafile.filename - pid = args.val_param_id - - config = fix_solve_parameters_with_pid(config, sample_name, checkpoints[0], - inference, pid) + threshold = config.validate_data.cell_score_threshold + pid = args.val_param_id + config = fix_solve_parameters_with_pids( + config, sample_name, checkpoints[0], inference, [pid], + threshold=threshold) + config.solve.parameters[0].val = False return config -def fix_solve_roi(config): - for i in range(len(config.solve.parameters)): - config.solve.parameters[i].roi = config.inference.data_source.roi - return config - - -def fix_evaluate_pid(args, config, checkpoints, inference): +def fix_param_pid(args, config, checkpoints, inference): assert len(checkpoints) == 1, "use param_id to reevaluate a single instance" sample_name = inference.data_sources[0].datafile.filename - pid = args.param_id + if hasattr(args, "param_ids") and args.param_ids is not None: + pids = list(range(int(args.param_ids[0]), int(args.param_ids[1])+1)) + else: + pids = [args.param_id] - config = fix_solve_parameters_with_pid(config, sample_name, checkpoints[0], - inference, pid) + config = fix_solve_parameters_with_pids(config, sample_name, + checkpoints[0], inference, pids) return config -def fix_evaluate_roi(config): - if config.evaluate.parameters.roi is not None: - assert config.evaluate.parameters.roi.shape[0] < \ - config.inference.data_source.roi.shape[0], \ - "your evaluation ROI is larger than your data roi!" - config.inference.data_source.roi = config.evaluate.parameters.roi + +def fix_solve_roi(config): + for i in range(len(config.solve.parameters)): + config.solve.parameters[i].roi = config.inference.data_source.roi return config +def fix_solve_parameters_with_pids(config, sample_name, checkpoint, inference, + pids, threshold=None): + if inference.data_sources[0].db_name is not None: + db_name = inference.data_sources[0].db_name + else: + db_name = checkOrCreateDB( + config.general.db_host, + config.general.setup_dir, + sample_name, + checkpoint, + threshold if threshold is not None + else inference.cell_score_threshold, + tag=config.general.tag, + create_if_not_found=False) + assert db_name is not None, "db for pid {} not found".format(pids) + pids_t = [] + for pid in pids: + if isinstance(pid, str): + if pid[0] in ("\"", "'"): + pid = int(pid[1:-1]) + else: + pid = int(pid) + pids_t.append(pid) + pids = pids_t -def fix_solve_parameters_with_pid(config, sample_name, checkpoint, inference, - pid): - db_name = checkOrCreateDB( - config.general.db_host, - config.general.setup_dir, - sample_name, - checkpoint, - inference.cell_score_threshold) db = CandidateDatabase(db_name, config.general.db_host) - parameters = db.get_parameters(pid) - logger.info("getting params %s (id: %s) from database %s (sample: %s)", - parameters, pid, db_name, sample_name) - try: - solve_parameters = [SolveParametersMinimalConfig(**parameters)] # type: ignore - config.solve.non_minimal = False - except TypeError: - solve_parameters = [SolveParametersNonMinimalConfig(**parameters)] # type: ignore - config.solve.non_minimal = True - config.solve.parameters = solve_parameters + config.solve.parameters = [] + for pid in pids: + # pid -= 1 + parameters = db.get_parameters(pid) + logger.info("getting params %s (id: %s) from database %s (sample: %s)", + parameters, pid, db_name, sample_name) + try: + solve_parameters = SolveParametersMinimalConfig(**parameters) # type: ignore + config.solve.non_minimal = False + except TypeError: + solve_parameters = SolveParametersNonMinimalConfig(**parameters) # type: ignore + config.solve.non_minimal = True + config.solve.parameters.append(solve_parameters) return config From 52ad78723731e181813188c84624e0cdd3de429a Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 7 Jun 2022 11:40:36 -0400 Subject: [PATCH 167/263] add support for data subsampling --- linajea/evaluation/evaluate_setup.py | 5 ++++- linajea/gunpowder/add_parent_vectors.py | 9 ++++++++- .../gunpowder/random_location_exclude_time.py | 6 ++++-- linajea/gunpowder/tracks_source.py | 20 ++++++++++++++++++- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index dab40ab..fd2752b 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -123,7 +123,10 @@ def evaluate_setup(linajea_config): logger.info("Reading ground truth cells and edges in db %s" % linajea_config.inference.data_source.gt_db_name) start_time = time.time() - gt_subgraph = gt_db[evaluate_roi] + gt_subgraph = gt_db.get_graph( + evaluate_roi, + subsampling=linajea_config.general.subsampling, + subsampling_seed=linajea_config.general.subsampling_seed) logger.info("Read %d cells and %d edges in %s seconds" % (gt_subgraph.number_of_nodes(), gt_subgraph.number_of_edges(), diff --git a/linajea/gunpowder/add_parent_vectors.py b/linajea/gunpowder/add_parent_vectors.py index deb7807..ef3a347 100644 --- a/linajea/gunpowder/add_parent_vectors.py +++ b/linajea/gunpowder/add_parent_vectors.py @@ -15,7 +15,8 @@ class AddParentVectors(BatchFilter): def __init__( self, points, array, mask, radius, - move_radius=0, array_spec=None, dense=False): + move_radius=0, array_spec=None, dense=False, + subsampling=None): self.points = points self.array = array @@ -28,6 +29,7 @@ def __init__( self.array_spec = array_spec self.dense = dense + self.subsampling = subsampling def setup(self): @@ -177,6 +179,11 @@ def __draw_parent_vectors( empty = True cnt = 0 total = 0 + for point_id, point in points.data.items(): + if self.subsampling is not None and point.value > self.subsampling: + logger.debug("skipping point %s %s due to subsampling %s", + point, point.value, self.subsampling) + continue avg_radius = [] for point in points.nodes: diff --git a/linajea/gunpowder/random_location_exclude_time.py b/linajea/gunpowder/random_location_exclude_time.py index 869571b..52d2440 100644 --- a/linajea/gunpowder/random_location_exclude_time.py +++ b/linajea/gunpowder/random_location_exclude_time.py @@ -15,13 +15,15 @@ def __init__( min_masked=0, mask=None, ensure_nonempty=None, - p_nonempty=1.0): + p_nonempty=1.0, + subsampling=None): super(RandomLocationExcludeTime, self).__init__( min_masked, mask, ensure_nonempty, - p_nonempty) + p_nonempty, + subsampling=subsampling) self.raw = raw if isinstance(time_interval, list) and \ diff --git a/linajea/gunpowder/tracks_source.py b/linajea/gunpowder/tracks_source.py index cdb4b13..9aac935 100644 --- a/linajea/gunpowder/tracks_source.py +++ b/linajea/gunpowder/tracks_source.py @@ -67,7 +67,7 @@ class TracksSource(BatchProvider): ''' def __init__(self, filename, points, points_spec=None, scale=1.0, - use_radius=False): + use_radius=False, subsampling_seed=42): self.filename = filename self.points = points @@ -79,6 +79,7 @@ def __init__(self, filename, points, points_spec=None, scale=1.0, self.use_radius = use_radius self.locations = None self.track_info = None + self.subsampling_seed = subsampling_seed def setup(self): @@ -176,3 +177,20 @@ def _read_points(self): self.filename, scale=self.scale, limit_to_roi=roi) + cnt_points = len(self.locations) + rng = np.random.default_rng(self.subsampling_seed) + shuffled_norm_idcs = rng.permutation(cnt_points)/(cnt_points-1) + logger.debug("permutation (seed %s): %s (min %s, max %s, cnt %s)", + self.subsampling_seed, + shuffled_norm_idcs, + np.min(shuffled_norm_idcs), + np.max(shuffled_norm_idcs), + len(shuffled_norm_idcs)) + if self.track_info.dtype == object: + for idx, tri in zip(shuffled_norm_idcs, self.track_info): + tri[3].append(idx) + else: + self.track_info = np.concatenate( + (self.track_info, np.reshape(shuffled_norm_idcs, + shuffled_norm_idcs.shape + (1,))), + axis=1, dtype=object) From 20d8c096f802ef06c886bb7940d50d67d5306691 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 08:04:07 -0400 Subject: [PATCH 168/263] ignore roi if querying parameters_id we can solve a large roi and evaluate chunks of it therefore during eval the roi might be different --- linajea/candidate_database.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index f94b774..bdd013b 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -146,12 +146,16 @@ def get_parameters_id( try: params_collection = self.database['parameters'] query = parameters.query() + del query['roi'] cnt = params_collection.count_documents(query) if fail_if_not_exists: - assert cnt > 0, "Did not find id for parameters %s"\ - " and fail_if_not_exists set to True" % query + assert cnt > 0, "Did not find id for parameters %s in %s"\ + " and fail_if_not_exists set to True" % ( + query, self.db_name) assert cnt <= 1, RuntimeError("multiple documents found in db" - " for these parameters: %s", query) + " for these parameters: %s: %s", + query, + list(params_collection.find(query))) if cnt == 1: find_result = params_collection.find_one(query) logger.info("Parameters %s already in collection with id %d" From 75b1afad226064662cce925f9220f50357c8998d Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 08:11:25 -0400 Subject: [PATCH 169/263] add function to get parameters for many param_ids at once --- linajea/candidate_database.py | 27 +++++++++++++++++++++++++-- linajea/get_next_inference_data.py | 8 +++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index bdd013b..913056f 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -212,6 +212,23 @@ def get_parameters(self, params_id): self._MongoDbGraphProvider__disconnect() return params + def get_parameters_many(self, params_ids): + '''Gets the parameters associated with the given ids. + Returns None per id if there are no parameters with the given id''' + self._MongoDbGraphProvider__connect() + self._MongoDbGraphProvider__open_db() + try: + params_collection = self.database['parameters'] + params_sets = [] + for pid in params_ids: + params = params_collection.find_one({'_id': pid}) + if params: + del params['_id'] + params_sets.append(params) + finally: + self._MongoDbGraphProvider__disconnect() + return params_sets + def set_parameters_id(self, parameters_id): '''Sets the parameters_id and selected_key for the CandidateDatabase, so that you can use reset_selection and/or get_selected_graph''' @@ -238,13 +255,18 @@ def get_score(self, parameters_id, eval_params=None): try: score_collection = self.database['scores'] query = {'param_id': parameters_id} + logger.debug("Eval params: %s", eval_params) if eval_params is not None: - query.update(eval_params.valid()) + if isinstance(eval_params, dict): + query.update(eval_params) + else: + query.update(eval_params.valid()) + logger.debug("Get score query: %s", query) old_score = score_collection.find_one(query) if old_score: del old_score['_id'] score = old_score - logger.info("loaded score for %s", query) + logger.debug("Loaded score for %s", query) finally: self._MongoDbGraphProvider__disconnect() return score @@ -273,6 +295,7 @@ def get_scores(self, filters=None, eval_params=None): score_collection = self.database['scores'] if eval_params is not None: query.update(eval_params.valid()) + logger.debug("Query: %s", query) scores = list(score_collection.find(query)) logger.debug("Found %d scores" % len(scores)) diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py index f64d969..088ae4c 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/get_next_inference_data.py @@ -153,9 +153,11 @@ def fix_solve_parameters_with_pids(config, sample_name, checkpoint, inference, db = CandidateDatabase(db_name, config.general.db_host) config.solve.parameters = [] - for pid in pids: - # pid -= 1 - parameters = db.get_parameters(pid) + parameters_sets = db.get_parameters_many(pids) + + for pid, parameters in zip(pids, parameters_sets): + if parameters is None: + continue logger.info("getting params %s (id: %s) from database %s (sample: %s)", parameters, pid, db_name, sample_name) try: From ef5eab7c5b4ea5249d808ba9243d45fff448b426 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 08:13:30 -0400 Subject: [PATCH 170/263] add function to get param_id for slightly rounded parameters if parameters are read from a text file there might be slight differences to the binary representation in the database. Round the values to the 10th decimal place and match --- linajea/candidate_database.py | 76 +++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/linajea/candidate_database.py b/linajea/candidate_database.py index 913056f..e69fb87 100644 --- a/linajea/candidate_database.py +++ b/linajea/candidate_database.py @@ -172,6 +172,82 @@ def get_parameters_id( return int(params_id) + def get_parameters_id_round( + self, + parameters, + fail_if_not_exists=False): + '''Get id for parameter set from mongo collection. + If fail_if_not_exists, fail if the parameter set isn't already there. + The default is to assign a new id and write it to the collection. + If parameters are read from a text file there might be some floating + point inaccuracies compared to values stored in the database. + ''' + self._MongoDbGraphProvider__connect() + self._MongoDbGraphProvider__open_db() + params_id = None + try: + params_collection = self.database['parameters'] + query = parameters.query() + del query['roi'] + entries = [] + params = list(query.keys()) + for entry in params_collection.find({}): + if "context" not in entry: + continue + for param in params: + if isinstance(query[param], float) or \ + (isinstance(query[param], int) and + not isinstance(query[param], bool)): + if round(entry[param], 10) != round(query[param], 10): + break + elif isinstance(query[param], dict): + if param == 'roi' and \ + (entry['roi']['offset'] != query['roi']['offset'] or \ + entry['roi']['shape'] != query['roi']['shape']): + break + elif param == 'cell_cycle_key': + if '$exists' in query['cell_cycle_key'] and \ + query['cell_cycle_key']['$exists'] == False and \ + 'cell_cycle_key' in entry: + break + elif isinstance(query[param], str): + if param not in entry or \ + entry[param] != query[param]: + break + elif isinstance(query[param], list): + if entry[param] != query[param]: + break + elif isinstance(query[param], bool): + if param not in entry or \ + entry[param] != query[param]: + break + else: + entries.append(entry) + cnt = len(entries) + if fail_if_not_exists: + assert cnt > 0, "Did not find id for parameters %s in %s"\ + " and fail_if_not_exists set to True" % ( + query, self.db_name) + assert cnt <= 1, RuntimeError("multiple documents found in db" + " for these parameters: %s: %s", + query, + entries) + if cnt == 1: + find_result = entries[0] + logger.info("Parameters %s already in collection with id %d" + % (query, find_result['_id'])) + params_id = find_result['_id'] + else: + params_id = self.insert_with_next_id(parameters.valid(), + params_collection) + logger.info("Parameters %s not yet in collection," + " adding with id %d", + query, params_id) + finally: + self._MongoDbGraphProvider__disconnect() + + return int(params_id) + def insert_with_next_id(self, document, collection): '''Inserts a new set of parameters into the database, assigning the next sequential int id From 15b836b56c1228e383b3441ff6f4c1ace62bb439 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 08:26:23 -0400 Subject: [PATCH 171/263] update config, some cleanup and new flags classify_dataset: to check if there are hidden properties in a data sample that allow the network to distinguish it from others (of the same organism) filter_short_tracklets_len: set to positve int value to filter shorter tracklets during evaluation val: set to true if parameters are for validation/during grid search grid/random search: update how it is handled in config random: selects random value in given range grid: computes all combinations, shuffle, random num_configs samples example: [solve] random_search = true grid_search = true [solve.parameters_search_random] weight_node_score = [-15, 5] selection_constant = [-7, 4] track_cost = [5, 20] weight_division = [-18, -5] division_constant = [5, 12] weight_child = [-0.5, 2.5] weight_continuation = [-2, -0] weight_edge_score = [0.25, 1.0] val = [true] cell_cycle_key = ['emb1_3_40_swa/test/40000_'] block_size = [[15, 512, 512, 712]] context = [[2, 100, 100, 100]] num_configs = 25 [solve.parameters_search_grid] weight_node_score = [-13, -17, -21] selection_constant = [6, 9, 12] track_cost = [7,] weight_division = [-8, -11] division_constant = [6.0, 2.5] weight_child = [1.0, 2.0] weight_continuation = [-1.0] weight_edge_score = [0.35] val = [true] cell_cycle_key = ['emb1_3_40_swa/test/40000_'] block_size = [[15, 512, 512, 712]] context = [[2, 100, 100, 100]] num_configs = 25 --- linajea/config/cnn_config.py | 1 + linajea/config/data.py | 1 - linajea/config/evaluate.py | 1 + linajea/config/general.py | 1 + linajea/config/solve.py | 54 ++++++++++++++++++++++++------------ linajea/config/train.py | 2 -- linajea/config/utils.py | 14 ++++++---- 7 files changed, 47 insertions(+), 27 deletions(-) diff --git a/linajea/config/cnn_config.py b/linajea/config/cnn_config.py index 6966c17..c7dfbcb 100644 --- a/linajea/config/cnn_config.py +++ b/linajea/config/cnn_config.py @@ -37,6 +37,7 @@ class CNNConfig: regularizer_weight = attr.ib(type=float, default=None) with_polar = attr.ib(type=int, default=False) focal_loss = attr.ib(type=bool, default=False) + classify_dataset = attr.ib(type=bool, default=False) @attr.s(kw_only=True) class VGGConfig(CNNConfig): diff --git a/linajea/config/data.py b/linajea/config/data.py index afb7ff5..768515a 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -39,7 +39,6 @@ def __attrs_post_init__(self): is_polar = "polar" in filename if is_polar: filename = filename.replace("_polar", "") - print(filename) if os.path.isdir(filename): data_config = load_config(os.path.join(filename, "data_config.toml")) diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 68751db..b275bde 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -15,6 +15,7 @@ class EvaluateParametersConfig: window_size = attr.ib(type=int, default=50) filter_polar_bodies = attr.ib(type=bool, default=None) filter_polar_bodies_key = attr.ib(type=str, default=None) + filter_short_tracklets_len = attr.ib(type=int, default=-1) ignore_one_off_div_errors = attr.ib(type=bool, default=False) fn_div_count_unconnected_parent = attr.ib(type=bool, default=True) # deprecated diff --git a/linajea/config/general.py b/linajea/config/general.py index 29881e9..8793d0f 100644 --- a/linajea/config/general.py +++ b/linajea/config/general.py @@ -13,6 +13,7 @@ class GeneralConfig: db_name = attr.ib(type=str, default=None) singularity_image = attr.ib(type=str, default=None) sparse = attr.ib(type=bool, default=True) + two_frame_edges = attr.ib(type=bool, default=False) tag = attr.ib(type=str, default=None) seed = attr.ib(type=int) logging = attr.ib(type=int) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 70e5ec2..550958e 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -67,6 +67,7 @@ class SolveParametersMinimalConfig: max_cell_move = attr.ib(type=int, default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) feature_func = attr.ib(type=str, default="noop") + val = attr.ib(type=bool, default=False) def valid(self): return {key: val @@ -98,9 +99,8 @@ class SolveParametersMinimalSearchConfig: # max_cell_move: currently use edge_move_threshold from extract max_cell_move = attr.ib(type=List[int], default=None) feature_func = attr.ib(type=List[str], default=["noop"]) - random_search = attr.ib(type=bool, default=False) - num_random_configs = attr.ib(type=int, default=None) - + num_configs = attr.ib(type=int, default=None) + val = attr.ib(type=List[bool], default=[True]) @attr.s(kw_only=True) class SolveParametersNonMinimalConfig: @@ -159,22 +159,20 @@ class SolveParametersNonMinimalSearchConfig: context = attr.ib(type=List[List[int]]) # max_cell_move: currently use edge_move_threshold from extract max_cell_move = attr.ib(type=List[int], default=None) - random_search = attr.ib(type=bool, default=False) - num_random_configs = attr.ib(type=int, default=None) + num_configs = attr.ib(type=int, default=None) -def write_solve_parameters_configs(parameters_search, non_minimal): +def write_solve_parameters_configs(parameters_search, non_minimal, grid): params = {k:v for k,v in attr.asdict(parameters_search).items() if v is not None} - params.pop('random_search', None) - params.pop('num_random_configs', None) + params.pop('num_configs', None) - if parameters_search.random_search: + if not grid: search_configs = [] - assert parameters_search.num_random_configs is not None, \ - "set number_configs kwarg when using random search!" + assert parameters_search.num_configs is not None, \ + "set num_configs kwarg when using random search!" - for _ in range(parameters_search.num_random_configs): + for _ in range(parameters_search.num_configs): conf = {} for k, v in params.items(): if not isinstance(v, list): @@ -213,9 +211,9 @@ def write_solve_parameters_configs(parameters_search, non_minimal): dict(zip(params.keys(), x)) for x in itertools.product(*params.values())] - if parameters_search.num_random_configs: + if parameters_search.num_configs: random.shuffle(search_configs) - search_configs = search_configs[:parameters_search.num_random_configs] + search_configs = search_configs[:parameters_search.num_configs] configs = [] for config_vals in search_configs: @@ -235,27 +233,47 @@ class SolveConfig: job = attr.ib(converter=ensure_cls(JobConfig)) from_scratch = attr.ib(type=bool, default=False) parameters = attr.ib(converter=convert_solve_params_list(), default=None) - parameters_search = attr.ib(converter=convert_solve_search_params(), default=None) + parameters_search_grid = attr.ib(converter=convert_solve_search_params(), + default=None) + parameters_search_random = attr.ib(converter=convert_solve_search_params(), + default=None) non_minimal = attr.ib(type=bool, default=False) greedy = attr.ib(type=bool, default=False) write_struct_svm = attr.ib(type=str, default=None) check_node_close_to_roi = attr.ib(type=bool, default=True) add_node_density_constraints = attr.ib(type=bool, default=False) timeout = attr.ib(type=int, default=120) + masked_nodes = attr.ib(type=str, default=None) clip_low_score = attr.ib(type=float, default=None) + grid_search = attr.ib(type=bool, default=False) + random_search = attr.ib(type=bool, default=False) def __attrs_post_init__(self): assert self.parameters is not None or \ - self.parameters_search is not None, \ + self.parameters_search_grid is not None or \ + self.parameters_search_random is not None, \ "provide either solve parameters or grid/random search values " \ "for solve parameters!" - if self.parameters_search is not None: + if self.grid_search or self.random_search: + assert self.grid_search != self.random_search, \ + "choose either grid or random search!" if self.parameters is not None: logger.warning("overwriting explicit solve parameters with " "grid/random search parameters!") + if self.grid_search: + assert self.parameters_search_grid is not None, \ + "provide grid search values for solve parameters " \ + "if grid search activated" + parameters_search = self.parameters_search_grid + else: #if self.random_search: + assert self.parameters_search_random is not None, \ + "provide random search values for solve parameters " \ + "if random search activated" + parameters_search = self.parameters_search_random self.parameters = write_solve_parameters_configs( - self.parameters_search, non_minimal=self.non_minimal) + parameters_search, non_minimal=self.non_minimal, + grid=self.grid_search) if self.greedy: config_vals = { diff --git a/linajea/config/train.py b/linajea/config/train.py index 0f073d9..c727dd4 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -61,5 +61,3 @@ class TrainTrackingConfig(TrainConfig): class TrainCellCycleConfig(TrainConfig): batch_size = attr.ib(type=int) augment = attr.ib(converter=ensure_cls(AugmentCellCycleConfig)) - # use_database = attr.ib(type=bool) - # database = attr.ib(converter=ensure_cls(DataDBConfig), default=None) diff --git a/linajea/config/utils.py b/linajea/config/utils.py index e400ea3..48677c6 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -99,12 +99,14 @@ def maybe_fix_config_paths_to_machine_and_load(config): config_dict["predict"]["path_to_script"] = config_dict["predict"]["path_to_script"].replace( "/groups/funke/home/hirschp/linajea_experiments", paths["HOME"]) - config_dict["predict"]["path_to_script_db_from_zarr"] = config_dict["predict"]["path_to_script_db_from_zarr"].replace( - "/groups/funke/home/hirschp/linajea_experiments", - paths["HOME"]) - config_dict["predict"]["output_zarr_prefix"] = config_dict["predict"]["output_zarr_prefix"].replace( - "/nrs/funke/hirschp/linajea_experiments", - paths["DATA"]) + if "path_to_script_db_from_zarr" in config_dict["predict"]: + config_dict["predict"]["path_to_script_db_from_zarr"] = config_dict["predict"]["path_to_script_db_from_zarr"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + if "output_zarr_prefix" in config_dict["predict"]: + config_dict["predict"]["output_zarr_prefix"] = config_dict["predict"]["output_zarr_prefix"].replace( + "/nrs/funke/hirschp", + paths["DATA"]) for dt in [config_dict["train_data"]["data_sources"], config_dict["test_data"]["data_sources"], config_dict["validate_data"]["data_sources"]]: From 3bfe501e5beb3bd212aa6aa985d3fe7bb9469418 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 08:33:36 -0400 Subject: [PATCH 172/263] check_or_create_db: add logging if db accessed or created --- linajea/check_or_create_db.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/linajea/check_or_create_db.py b/linajea/check_or_create_db.py index 70e9864..cf4301b 100644 --- a/linajea/check_or_create_db.py +++ b/linajea/check_or_create_db.py @@ -1,8 +1,11 @@ import datetime +import logging import os import pymongo +logger = logging.getLogger(__name__) + def checkOrCreateDB(db_host, setup_dir, sample, checkpoint, cell_score_threshold, prefix="linajea_", @@ -45,6 +48,7 @@ def checkOrCreateDBMeta(db_host, db_meta_info, prefix="linajea_", query_result = db["db_meta_info"].find_one() del query_result["_id"] if query_result == db_meta_info: + logger.info("{}: {} (accessed)".format(db_name, query_result)) break else: if not create_if_not_found: @@ -53,5 +57,6 @@ def checkOrCreateDBMeta(db_host, db_meta_info, prefix="linajea_", datetime.datetime.now(tz=datetime.timezone.utc).strftime( '%Y%m%d_%H%M%S')) client[db_name]["db_meta_info"].insert_one(db_meta_info) + logger.info("{}: {} (created)".format(db_name, db_meta_info)) return db_name From 888eadccf2fb3491bb5f94837ea1d674f4cc35cc Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 08:38:19 -0400 Subject: [PATCH 173/263] update evaluate_setup move some code to sep. functions for clarity add code to handle edges that cover two frames --- linajea/evaluation/evaluate_setup.py | 262 +++++++++++++++++++++------ 1 file changed, 205 insertions(+), 57 deletions(-) diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index fd2752b..ddf244b 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -4,8 +4,11 @@ import time import networkx as nx +import numpy as np +import scipy.spatial import daisy +import funlib.math import linajea.tracking from .evaluate import evaluate @@ -41,7 +44,11 @@ def evaluate_setup(linajea_config): if old_score: logger.info("Already evaluated %d (%s). Skipping" % (parameters_id, linajea_config.evaluate.parameters)) - logger.info("Stored results: %s", old_score) + score = {} + for k, v in old_score.items(): + if not isinstance(k, list) or k != "roi": + score[k] = v + logger.info("Stored results: %s", score) return logger.info("Evaluating %s in %s", @@ -60,6 +67,9 @@ def evaluate_setup(linajea_config): subgraph.number_of_edges(), time.time() - start_time)) + if linajea_config.general.two_frame_edges: + subgraph = split_two_frame_edges(linajea_config, subgraph, evaluate_roi) + if subgraph.number_of_edges() == 0: logger.warn("No selected edges for parameters_id %d. Skipping" % parameters_id) @@ -67,52 +77,11 @@ def evaluate_setup(linajea_config): if linajea_config.evaluate.parameters.filter_polar_bodies or \ linajea_config.evaluate.parameters.filter_polar_bodies_key: - logger.debug("%s %s", - linajea_config.evaluate.parameters.filter_polar_bodies, - linajea_config.evaluate.parameters.filter_polar_bodies_key) - if not linajea_config.evaluate.parameters.filter_polar_bodies and \ - linajea_config.evaluate.parameters.filter_polar_bodies_key is not None: - pb_key = linajea_config.evaluate.parameters.filter_polar_bodies_key - else: - pb_key = parameters.cell_cycle_key + "polar" - - # temp. remove edges from mother to daughter cells to split into chains - tmp_subgraph = edges_db.get_selected_graph(evaluate_roi) - for node in list(tmp_subgraph.nodes()): - if tmp_subgraph.degree(node) > 2: - es = list(tmp_subgraph.predecessors(node)) - tmp_subgraph.remove_edge(es[0], node) - tmp_subgraph.remove_edge(es[1], node) - rec_graph = linajea.tracking.TrackGraph( - tmp_subgraph, frame_key='t', roi=tmp_subgraph.roi) - - # for each chain - for track in rec_graph.get_tracks(): - cnt_nodes = 0 - cnt_polar = 0 - cnt_polar_uninterrupted = [[]] - nodes = [] - for node_id, node in track.nodes(data=True): - nodes.append((node['t'], node_id, node)) - - # check if > 50% are polar bodies - nodes = sorted(nodes) - for _, node_id, node in nodes: - cnt_nodes += 1 - try: - if node[pb_key] > 0.5: - cnt_polar += 1 - cnt_polar_uninterrupted[-1].append(node_id) - else: - cnt_polar_uninterrupted.append([]) - except KeyError: - pass - - # then remove - if cnt_polar/cnt_nodes > 0.5: - subgraph.remove_nodes_from(track.nodes()) - logger.info("removing %s potential polar nodes", - len(track.nodes())) + subgraph = filter_polar_bodies(linajea_config, subgraph, + edges_db, evaluate_roi) + + subgraph = maybe_filter_short_tracklets(linajea_config, subgraph, + evaluate_roi) track_graph = linajea.tracking.TrackGraph( subgraph, frame_key='t', roi=subgraph.roi) @@ -135,15 +104,8 @@ def evaluate_setup(linajea_config): if linajea_config.inference.data_source.gt_db_name_polar is not None and \ not linajea_config.evaluate.parameters.filter_polar_bodies and \ not linajea_config.evaluate.parameters.filter_polar_bodies_key: - logger.info("polar bodies are not filtered, adding polar body GT..") - gt_db_polar = linajea.CandidateDatabase( - linajea_config.inference.data_source.gt_db_name_polar, db_host) - gt_polar_subgraph = gt_db_polar[evaluate_roi] - gt_mx_id = max(gt_subgraph.nodes()) + 1 - mapping = {n: n+gt_mx_id for n in gt_polar_subgraph.nodes()} - gt_polar_subgraph = nx.relabel_nodes(gt_polar_subgraph, mapping, - copy=False) - gt_subgraph.update(gt_polar_subgraph) + gt_subgraph = add_gt_polar_bodies(linajea_config, gt_subgraph, + db_host, evaluate_roi) gt_track_graph = linajea.tracking.TrackGraph( gt_subgraph, frame_key='t', roi=gt_subgraph.roi) @@ -155,13 +117,14 @@ def evaluate_setup(linajea_config): linajea_config.evaluate.parameters.ignore_one_off_div_errors fn_div_count_unconnected_parent = \ linajea_config.evaluate.parameters.fn_div_count_unconnected_parent + window_size=linajea_config.evaluate.parameters.window_size report = evaluate( gt_track_graph, track_graph, matching_threshold=matching_threshold, sparse=linajea_config.general.sparse, validation_score=validation_score, - window_size=linajea_config.evaluate.parameters.window_size, + window_size=window_size, ignore_one_off_div_errors=ignore_one_off_div_errors, fn_div_count_unconnected_parent=fn_div_count_unconnected_parent) @@ -170,3 +133,188 @@ def evaluate_setup(linajea_config): logger.info("Result summary: %s", report.get_short_report()) results_db.write_score(parameters_id, report, eval_params=linajea_config.evaluate.parameters) + res = report.get_short_report() + print("| | fp | fn | id | fp_div | fn_div | sum_div |" + " sum | DET | TRA | REFT | NR | ER | GT |") + sum_errors = (res['fp_edges'] + res['fn_edges'] + + res['identity_switches'] + + res['fp_divisions'] + res['fn_divisions']) + sum_divs = res['fp_divisions'] + res['fn_divisions'] + reft = res["num_error_free_tracks"]/res["num_gt_cells_last_frame"] + print("| {:3d} | {:3d} | {:3d} |" + " {:3d} | {:3d} | {:3d} | {:3d} | | |" + " {:.4f} | {:.4f} | {:.4f} | {:5d} |".format( + int(res['fp_edges']), int(res['fn_edges']), + int(res['identity_switches']), + int(res['fp_divisions']), int(res['fn_divisions']), + int(sum_divs), + int(sum_errors), + reft, res['node_recall'], res['edge_recall'], + int(res['gt_edges']))) + + +def split_two_frame_edges(linajea_config, subgraph, evaluate_roi): + voxel_size = daisy.Coordinate(linajea_config.inference.data_source.voxel_size) + cells_by_frame = {} + for cell in subgraph.nodes: + cell = subgraph.nodes[cell] + t = cell['t'] + if t not in cells_by_frame: + cells_by_frame[t] = [] + cell_pos = np.array([cell['z'], cell['y'], cell['x']]) + cells_by_frame[t].append(cell_pos) + kd_trees_by_frame = {} + for t, cells in cells_by_frame.items(): + kd_trees_by_frame[t] = scipy.spatial.cKDTree(cells) + edges = list(subgraph.edges) + for u_id, v_id in edges: + u = subgraph.nodes[u_id] + v = subgraph.nodes[v_id] + frame_diff = abs(u['t']-v['t']) + if frame_diff == 1: + continue + elif frame_diff == 0: + raise RuntimeError("invalid edges? no diff in t %s %s", u, v) + elif frame_diff == 2: + u_pos = np.array([u['z'], u['y'], u['x']]) + v_pos = np.array([v['z'], v['y'], v['x']]) + w = {} + for k in u.keys(): + if k not in v.keys(): + continue + if "probable_gt" in k: + continue + u_v = u[k] + v_v = v[k] + + if "parent_vector" in k: + u_v = np.array(u_v) + v_v = np.array(v_v) + w[k] = (u_v + v_v)/2 + if "parent_vector" in k: + w[k] = list(w[k]) + w['t'] = u['t'] - 1 + w_id = int(funlib.math.cantor_number( + [i1/i2+i3 + for i1,i2,i3 in zip(evaluate_roi.get_begin(), + voxel_size, + [w['t'], w['z'], w['y'], w['x']])])) + d, i = kd_trees_by_frame[w['t']].query( + np.array([w['z'], w['y'], w['x']])) + print("inserted cell from two-frame-edge {} {} -> {}".format( + (u_id, u['t'], u_pos), (v_id, v['t'], v_pos), (w_id, [w['t'], w['z'], w['y'], w['x']]))) + subgraph.remove_edge(u_id, v_id) + if d <= 7: + print("inserted cell already exists {}".format(cells_by_frame[w['t']][i])) + else: + subgraph.add_node(w_id, **w) + subgraph.add_edge(u_id, w_id) + subgraph.add_edge(w_id, v_id) + else: + raise RuntimeError("invalid edges? diff of %s in t %s %s", frame_diff, u, v) + + logger.info("After splitting two_frame edges: %d cells and %d edges" + % (subgraph.number_of_nodes(), + subgraph.number_of_edges())) + + return subgraph + + +def filter_polar_bodies(linajea_config, subgraph, edges_db, evaluate_roi): + logger.debug("%s %s", + linajea_config.evaluate.parameters.filter_polar_bodies, + linajea_config.evaluate.parameters.filter_polar_bodies_key) + if not linajea_config.evaluate.parameters.filter_polar_bodies and \ + linajea_config.evaluate.parameters.filter_polar_bodies_key is not None: + pb_key = linajea_config.evaluate.parameters.filter_polar_bodies_key + else: + pb_key = linajea_config.solve.parameters[0].cell_cycle_key + "polar" + + # temp. remove edges from mother to daughter cells to split into chains + tmp_subgraph = edges_db.get_selected_graph(evaluate_roi) + for node in list(tmp_subgraph.nodes()): + if tmp_subgraph.degree(node) > 2: + es = list(tmp_subgraph.predecessors(node)) + tmp_subgraph.remove_edge(es[0], node) + tmp_subgraph.remove_edge(es[1], node) + rec_graph = linajea.tracking.TrackGraph( + tmp_subgraph, frame_key='t', roi=tmp_subgraph.roi) + + # for each chain + for track in rec_graph.get_tracks(): + cnt_nodes = 0 + cnt_polar = 0 + cnt_polar_uninterrupted = [[]] + nodes = [] + for node_id, node in track.nodes(data=True): + nodes.append((node['t'], node_id, node)) + + # check if > 50% are polar bodies + nodes = sorted(nodes) + for _, node_id, node in nodes: + cnt_nodes += 1 + try: + if node[pb_key] > 0.5: + cnt_polar += 1 + cnt_polar_uninterrupted[-1].append(node_id) + else: + cnt_polar_uninterrupted.append([]) + except KeyError: + pass + + # then remove + if cnt_polar/cnt_nodes > 0.5: + subgraph.remove_nodes_from(track.nodes()) + logger.info("removing %s potential polar nodes", + len(track.nodes())) + # else: + # for ch in cnt_polar_uninterrupted: + # if len(ch) > 5: + # subgraph.remove_nodes_from(ch) + # logger.info("removing %s potential polar nodes (2)", len(ch)) + + return subgraph + +def maybe_filter_short_tracklets(linajea_config, subgraph, evaluate_roi): + track_graph_tmp = linajea.tracking.TrackGraph( + subgraph, frame_key='t', roi=subgraph.roi) + last_frame = evaluate_roi.get_end()[0]-1 + for track in track_graph_tmp.get_tracks(): + min_t = 9999 + max_t = -1 + for node_id, node in track.nodes(data=True): + if node['t'] < min_t: + min_t = node['t'] + if node['t'] > max_t: + max_t = node['t'] + + logger.info("track begin: {}, track end: {}, track len: {}".format( + min_t, max_t, len(track.nodes()))) + + if len(track.nodes()) < linajea_config.evaluate.filter_short_tracklets_len \ + and max_t != last_frame: + logger.info("removing %s nodes (very short tracks < %d)", + len(track.nodes()), + linajea_config.evaluate.filter_short_tracklets_len) + subgraph.remove_nodes_from(track.nodes()) + + return subgraph + + +def add_gt_polar_bodies(linajea_config, gt_subgraph, db_host, evaluate_roi): + logger.info("polar bodies are not filtered, adding polar body GT..") + gt_db_polar = linajea.CandidateDatabase( + linajea_config.inference.data_source.gt_db_name_polar, db_host) + gt_polar_subgraph = gt_db_polar[evaluate_roi] + gt_mx_id = max(gt_subgraph.nodes()) + 1 + mapping = {n: n+gt_mx_id for n in gt_polar_subgraph.nodes()} + gt_polar_subgraph = nx.relabel_nodes(gt_polar_subgraph, mapping, + copy=False) + gt_subgraph.update(gt_polar_subgraph) + + logger.info("Read %d cells and %d edges in %s seconds (after adding polar bodies)" + % (gt_subgraph.number_of_nodes(), + gt_subgraph.number_of_edges(), + time.time() - start_time)) + + return gt_subgraph From 4e953725ab44bbbfaa4247b21f3ad83696fc24b5 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:08:05 -0400 Subject: [PATCH 174/263] append sample name to ssvm dir --- linajea/get_next_inference_data.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/linajea/get_next_inference_data.py b/linajea/get_next_inference_data.py index 088ae4c..3dcee39 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/get_next_inference_data.py @@ -75,6 +75,9 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): config.inference = InferenceDataTrackingConfig(**inference_data) # type: ignore if is_solve: config = fix_solve_roi(config) + if config.solve.write_struct_svm: + config.solve.write_struct_svm += "_ckpt_{}_{}".format( + checkpoint, os.path.basename(sample.datafile.filename)) if is_evaluate: for solve_parameters in solve_parameters_sets: From a5907aefdd9a4dc6decc3fe222725b7667820147 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:10:17 -0400 Subject: [PATCH 175/263] add_parent_vectors: return deps, rename var --- linajea/gunpowder/add_parent_vectors.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/linajea/gunpowder/add_parent_vectors.py b/linajea/gunpowder/add_parent_vectors.py index ef3a347..899bd8e 100644 --- a/linajea/gunpowder/add_parent_vectors.py +++ b/linajea/gunpowder/add_parent_vectors.py @@ -1,6 +1,7 @@ from gunpowder import BatchFilter from gunpowder.array import Array from gunpowder.array_spec import ArraySpec +from gunpowder.batch_request import BatchRequest from gunpowder.coordinate import Coordinate from gunpowder.morphology import enlarge_binary_map from gunpowder.graph_spec import GraphSpec @@ -75,7 +76,9 @@ def prepare(self, request): # however, restrict the request to the points actually provided points_roi = points_roi.intersect(self.spec[self.points].roi) logger.debug("Requesting points in roi %s" % points_roi) - request[self.points] = GraphSpec(roi=points_roi) + deps = BatchRequest() + deps[self.points] = GraphSpec(roi=points_roi) + return deps def process(self, batch, request): @@ -236,9 +239,9 @@ def __draw_parent_vectors( voxel_size, in_place=True) - coords = np.argwhere(mask_tmp) - _, z_min, y_min, x_min = coords.min(axis=0) - _, z_max, y_max, x_max = coords.max(axis=0) + mask_coords = np.argwhere(mask_tmp) + _, z_min, y_min, x_min = mask_coords.min(axis=0) + _, z_max, y_max, x_max = mask_coords.max(axis=0) mask_cut = mask_tmp[:, z_min:z_max+1, y_min:y_max+1, From 042940cae179d068dcc6a93107ed430dcb26e07e Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:12:09 -0400 Subject: [PATCH 176/263] parse_tracks_file: set track_info array type to object --- linajea/parse_tracks_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linajea/parse_tracks_file.py b/linajea/parse_tracks_file.py index 40756bf..754bb0d 100644 --- a/linajea/parse_tracks_file.py +++ b/linajea/parse_tracks_file.py @@ -105,4 +105,4 @@ def _parse_csv_fields(filename, scale=1.0, limit_to_roi=None): if 'div_state' in row: track_info[-1].append(int(row['div_state'])) - return np.array(locations), np.array(track_info) + return np.array(locations), np.array(track_info, dtype=object) From 2d82d8a1d40cc8b9172050c51b2e102a25b08ab8 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:16:41 -0400 Subject: [PATCH 177/263] predict_blockwise: add code for other hpc systems --- .../process_blockwise/predict_blockwise.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index b556133..fcbffc7 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -174,13 +174,6 @@ def predict_worker(linajea_config): worker_time = time.time() job = linajea_config.predict.job - if job.singularity_image is not None: - image_path = '/nrs/funke/singularity/' - image = image_path + job.singularity_image + '.img' - logger.debug("Using singularity image %s" % image) - else: - image = None - if linajea_config.predict.write_db_from_zarr: path_to_script = linajea_config.predict.path_to_script_db_from_zarr else: @@ -192,20 +185,26 @@ def predict_worker(linajea_config): if job.local: cmd = [command] - else: + elif os.path.isdir("/nrs/funke"): cmd = run( - command=command, + command=command.split(" "), queue=job.queue, num_gpus=1, num_cpus=linajea_config.predict.processes_per_worker, - singularity_image=image, + singularity_image=job.singularity_image, mount_dirs=['/groups', '/nrs'], execute=False, expand=False, flags=['-P ' + job.lab] if job.lab is not None else None ) + elif os.path.isdir("/fast/work/users"): + cmd = ['sbatch', '../run_slurm_gpu.sh'] + command[1:] + else: + raise RuntimeError("cannot detect hpc system!") logger.info("Starting predict worker...") + cmd = ["\"{}\"".format(c) if "affinity" in c else c for c in cmd] + cmd = ["\"{}\"".format(c) if "rusage" in c else c for c in cmd] logger.info("Command: %s" % str(cmd)) os.makedirs('logs', exist_ok=True) daisy.call( From 0b226e52db7113fbb8064fd90119eba45f6e3ab2 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:19:18 -0400 Subject: [PATCH 178/263] extract_edges_blockwise: add option for edges to skip a frame --- .../extract_edges_blockwise.py | 98 ++++++++++--------- 1 file changed, 50 insertions(+), 48 deletions(-) diff --git a/linajea/process_blockwise/extract_edges_blockwise.py b/linajea/process_blockwise/extract_edges_blockwise.py index 33b6442..218135e 100644 --- a/linajea/process_blockwise/extract_edges_blockwise.py +++ b/linajea/process_blockwise/extract_edges_blockwise.py @@ -126,73 +126,75 @@ def extract_edges_in_block( for cell, attrs in graph.nodes(data=True) if 't' in attrs and attrs['t'] == t ] - for t in range(t_begin - 1, t_end) + for t in range(t_begin - (2 if linajea_config.general.two_frame_edges else 1), + t_end) } for t in range(t_begin, t_end): - pre = t - 1 - nex = t + for fd in [1,2] if linajea_config.general.two_frame_edges else [1]: + pre = t - fd + nex = t - logger.debug( - "Finding edges between cells in frames %d and %d " - "(%d and %d cells)", - pre, nex, len(cells_by_t[pre]), len(cells_by_t[nex])) + logger.debug( + "Finding edges between cells in frames %d and %d " + "(%d and %d cells)", + pre, nex, len(cells_by_t[pre]), len(cells_by_t[nex])) - if len(cells_by_t[pre]) == 0 or len(cells_by_t[nex]) == 0: + if len(cells_by_t[pre]) == 0 or len(cells_by_t[nex]) == 0: - logger.debug("There are no edges between these frames, skipping") - continue + logger.debug("There are no edges between these frames, skipping") + continue - # prepare KD tree for fast lookup of 'pre' cells - logger.debug("Preparing KD tree...") - all_pre_cells = cells_by_t[pre] - kd_data = [cell[1] for cell in all_pre_cells] - pre_kd_tree = cKDTree(kd_data) + # prepare KD tree for fast lookup of 'pre' cells + logger.debug("Preparing KD tree...") + all_pre_cells = cells_by_t[pre] + kd_data = [cell[1] for cell in all_pre_cells] + pre_kd_tree = cKDTree(kd_data) - for th, val in linajea_config.extract.edge_move_threshold.items(): - if th == -1 or t < int(th): - edge_move_threshold = val - break + for th, val in linajea_config.extract.edge_move_threshold.items(): + if th == -1 or t < int(th): + edge_move_threshold = val + break - for i, nex_cell in enumerate(cells_by_t[nex]): + for i, nex_cell in enumerate(cells_by_t[nex]): - nex_cell_id = nex_cell[0] - nex_cell_center = nex_cell[1] - nex_parent_center = nex_cell_center + nex_cell[2] + nex_cell_id = nex_cell[0] + nex_cell_center = nex_cell[1] + nex_parent_center = nex_cell_center + nex_cell[2] - if linajea_config.extract.use_pv_distance: - pre_cells_indices = pre_kd_tree.query_ball_point( - nex_parent_center, - edge_move_threshold) - else: - pre_cells_indices = pre_kd_tree.query_ball_point( - nex_cell_center, - edge_move_threshold) - pre_cells = [all_pre_cells[i] for i in pre_cells_indices] + if linajea_config.extract.use_pv_distance: + pre_cells_indices = pre_kd_tree.query_ball_point( + nex_parent_center, + edge_move_threshold) + else: + pre_cells_indices = pre_kd_tree.query_ball_point( + nex_cell_center, + edge_move_threshold) + pre_cells = [all_pre_cells[i] for i in pre_cells_indices] - logger.debug( - "Linking to %d cells in previous frame", - len(pre_cells)) + logger.debug( + "Linking to %d cells in previous frame", + len(pre_cells)) - if len(pre_cells) == 0: - continue + if len(pre_cells) == 0: + continue - for pre_cell in pre_cells: + for pre_cell in pre_cells: - pre_cell_id = pre_cell[0] - pre_cell_center = pre_cell[1] + pre_cell_id = pre_cell[0] + pre_cell_center = pre_cell[1] - moved = (pre_cell_center - nex_cell_center) - distance = np.linalg.norm(moved) + moved = (pre_cell_center - nex_cell_center) + distance = np.linalg.norm(moved) - prediction_offset = (pre_cell_center - nex_parent_center) - prediction_distance = np.linalg.norm(prediction_offset) + prediction_offset = (pre_cell_center - nex_parent_center) + prediction_distance = np.linalg.norm(prediction_offset) - graph.add_edge( - nex_cell_id, pre_cell_id, - distance=distance, - prediction_distance=prediction_distance) + graph.add_edge( + nex_cell_id, pre_cell_id, + distance=distance, + prediction_distance=prediction_distance) logger.debug("Found %d edges", graph.number_of_edges()) From 1c6bdffe9abef3f9fda01a6a7f2ca2ff099ddde0 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:21:35 -0400 Subject: [PATCH 179/263] greedy_track: add option to pass graph instead of db --- linajea/tracking/greedy_track.py | 37 +++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/linajea/tracking/greedy_track.py b/linajea/tracking/greedy_track.py index 947789a..e23e50b 100644 --- a/linajea/tracking/greedy_track.py +++ b/linajea/tracking/greedy_track.py @@ -33,23 +33,36 @@ def load_graph( def greedy_track( - db_name, - db_host, - selected_key, - cell_indicator_threshold, + graph=None, + db_name=None, + db_host=None, + selected_key=None, + cell_indicator_threshold=None, metric='prediction_distance', frame_key='t', allow_new_tracks=True, roi=None): - cand_db = CandidateDatabase(db_name, db_host, 'r+') - total_roi = cand_db.get_nodes_roi() + if graph is None: + cand_db = CandidateDatabase(db_name, db_host, 'r+') + total_roi = cand_db.get_nodes_roi() + else: + if graph.number_of_nodes() == 0: + logger.info("No nodes in graph - skipping solving step") + return + cand_db = None + total_roi = graph.roi + if roi is not None: total_roi = roi.intersect(total_roi) start_frame = total_roi.get_offset()[0] end_frame = start_frame + total_roi.get_shape()[0] logger.info("Tracking from frame %d to frame %d", end_frame, start_frame) - step = 10 + if graph is None: + step = 10 + else: + step = end_frame - start_frame + selected_prev_nodes = set() first = True for section_end in range(end_frame, start_frame, -1*step): @@ -59,6 +72,7 @@ def greedy_track( section_roi = total_roi.intersect(frames_roi) logger.info("Greedy tracking in section %s", str(section_roi)) selected_prev_nodes = track_section( + graph, cand_db, section_roi, selected_key, @@ -73,6 +87,7 @@ def greedy_track( def track_section( + graph, cand_db, roi, selected_key, @@ -85,8 +100,10 @@ def track_section( # this function solves this whole section and stores the result, and # returns the node ids in the preceeding frame (before roi!) that were # selected - - graph = load_graph(cand_db, roi, selected_key) + if graph is None: + graph = load_graph(cand_db, roi, selected_key) + else: + nx.set_edge_attributes(graph, False, selected_key) track_graph = TrackGraph(graph_data=graph, frame_key=frame_key, roi=roi) start_frame = roi.get_offset()[0] end_frame = start_frame + roi.get_shape()[0] - 1 @@ -107,7 +124,7 @@ def track_section( seeds = seeds - selected_prev_nodes logger.debug("Found %d seeds in frame %d", len(seeds), frame) - if first: + if first and frame == end_frame: # in this special case, all seeds are treated as selected selected_prev_nodes = seeds seeds = set() From 96a8caa3b7a51340175c291c50874555a9e1a8bb Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:24:51 -0400 Subject: [PATCH 180/263] mamut vis: some minor cleanup --- .../mamut/mamut_matched_tracks_reader.py | 101 +++++++++--------- linajea/visualization/mamut/mamut_writer.py | 9 +- 2 files changed, 58 insertions(+), 52 deletions(-) diff --git a/linajea/visualization/mamut/mamut_matched_tracks_reader.py b/linajea/visualization/mamut/mamut_matched_tracks_reader.py index adafc42..23ebf8c 100644 --- a/linajea/visualization/mamut/mamut_matched_tracks_reader.py +++ b/linajea/visualization/mamut/mamut_matched_tracks_reader.py @@ -14,56 +14,59 @@ def __init__(self, db_host): self.db_host = db_host def read_data(self, data): - candidate_db_name = data['db_name'] - start_frame, end_frame = data['frames'] - matching_threshold = data.get('matching_threshold', 20) - gt_db_name = data['gt_db_name'] - assert end_frame > start_frame - roi = Roi((start_frame, 0, 0, 0), - (end_frame - start_frame, 1e10, 1e10, 1e10)) - if 'parameters_id' in data: - try: - int(data['parameters_id']) - selected_key = 'selected_' + str(data['parameters_id']) - except: - selected_key = data['parameters_id'] + if isinstance(data, tuple) and len(data) == 2: + (gt_tracks, matched_rec_tracks) = data else: - selected_key = None - db = linajea.CandidateDatabase( - candidate_db_name, self.db_host) - db.selected_key = selected_key - gt_db = linajea.CandidateDatabase(gt_db_name, self.db_host) - - print("Reading GT cells and edges in %s" % roi) - gt_subgraph = gt_db[roi] - gt_graph = linajea.tracking.TrackGraph(gt_subgraph, frame_key='t') - gt_tracks = list(gt_graph.get_tracks()) - print("Found %d GT tracks" % len(gt_tracks)) - - # tracks_to_xml(gt_cells, gt_tracks, 'linajea_gt.xml') - - print("Reading cells and edges in %s" % roi) - subgraph = db.get_selected_graph(roi) - graph = linajea.tracking.TrackGraph(subgraph, frame_key='t') - tracks = list(graph.get_tracks()) - print("Found %d tracks" % len(tracks)) - - if len(graph.nodes) == 0 or len(gt_graph.nodes) == 0: - logger.info("Didn't find gt or reconstruction - returning") - return [], [] - - m = linajea.evaluation.match_edges( - gt_graph, graph, - matching_threshold=matching_threshold) - (edges_x, edges_y, edge_matches, edge_fps) = m - matched_rec_tracks = [] - for track in tracks: - for _, edge_index in edge_matches: - edge = edges_y[edge_index] - if track.has_edge(edge[0], edge[1]): - matched_rec_tracks.append(track) - break - logger.debug("found %d matched rec tracks" % len(matched_rec_tracks)) + candidate_db_name = data['db_name'] + start_frame, end_frame = data['frames'] + matching_threshold = data.get('matching_threshold', 20) + gt_db_name = data['gt_db_name'] + assert end_frame > start_frame + roi = Roi((start_frame, 0, 0, 0), + (end_frame - start_frame, 1e10, 1e10, 1e10)) + if 'parameters_id' in data: + try: + int(data['parameters_id']) + selected_key = 'selected_' + str(data['parameters_id']) + except: + selected_key = data['parameters_id'] + else: + selected_key = None + db = linajea.CandidateDatabase( + candidate_db_name, self.db_host) + db.selected_key = selected_key + gt_db = linajea.CandidateDatabase(gt_db_name, self.db_host) + + print("Reading GT cells and edges in %s" % roi) + gt_subgraph = gt_db[roi] + gt_graph = linajea.tracking.TrackGraph(gt_subgraph, frame_key='t') + gt_tracks = list(gt_graph.get_tracks()) + print("Found %d GT tracks" % len(gt_tracks)) + + # tracks_to_xml(gt_cells, gt_tracks, 'linajea_gt.xml') + + print("Reading cells and edges in %s" % roi) + subgraph = db.get_selected_graph(roi) + graph = linajea.tracking.TrackGraph(subgraph, frame_key='t') + tracks = list(graph.get_tracks()) + print("Found %d tracks" % len(tracks)) + + if len(graph.nodes) == 0 or len(gt_graph.nodes) == 0: + logger.info("Didn't find gt or reconstruction - returning") + return [], [] + + m = linajea.evaluation.match_edges( + gt_graph, graph, + matching_threshold=matching_threshold) + (edges_x, edges_y, edge_matches, edge_fps) = m + matched_rec_tracks = [] + for track in tracks: + for _, edge_index in edge_matches: + edge = edges_y[edge_index] + if track.has_edge(edge[0], edge[1]): + matched_rec_tracks.append(track) + break + logger.debug("found %d matched rec tracks" % len(matched_rec_tracks)) logger.info("Adding %d gt tracks" % len(gt_tracks)) track_id = 0 diff --git a/linajea/visualization/mamut/mamut_writer.py b/linajea/visualization/mamut/mamut_writer.py index f60bc66..d3f9f74 100644 --- a/linajea/visualization/mamut/mamut_writer.py +++ b/linajea/visualization/mamut/mamut_writer.py @@ -51,7 +51,10 @@ def remap_cell_ids(self, cells, tracks): % self.max_cell_id) def add_data(self, mamut_reader, data): - cells, tracks = mamut_reader.read_data(data) + if isinstance(data, tuple) and len(data) == 2: + cells, tracks = mamut_reader.read_data(data) + else: + cells, tracks = mamut_reader.read_data(data) self.remap_cell_ids(cells, tracks) logger.info("Adding %d cells, %d tracks" % (len(cells), len(tracks))) @@ -136,8 +139,8 @@ def cells_to_xml(self, output, scale=1.0): frame=t, quality=score, z=z*scale, - y=y*scale, - x=x*scale)) + y=y, + x=x)) output.write(inframe_end_template) From 3e9e8810e94a648b807bf6e8c01e91359c3b674b Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:28:34 -0400 Subject: [PATCH 181/263] write_ctc: cleanup, add option to pass in radii of nodes --- linajea/visualization/ctc/write_ctc.py | 167 +++++++++++++++---------- 1 file changed, 99 insertions(+), 68 deletions(-) diff --git a/linajea/visualization/ctc/write_ctc.py b/linajea/visualization/ctc/write_ctc.py index e7b2929..007c807 100644 --- a/linajea/visualization/ctc/write_ctc.py +++ b/linajea/visualization/ctc/write_ctc.py @@ -29,7 +29,7 @@ def watershed(surface, markers, fg): def write_ctc(graph, start_frame, end_frame, shape, out_dir, txt_fn, tif_fn, paint_sphere=False, voxel_size=None, gt=False, surface=None, fg_threshold=0.5, - mask=None): + mask=None, radii=None): os.makedirs(out_dir, exist_ok=True) logger.info("writing frames %s,%s (graph %s)", @@ -82,7 +82,6 @@ def write_ctc(graph, start_frame, end_frame, shape, node_to_track[e[1]][0], node_to_track[e[1]][1], f) - # curr_cells.remove(e[0]) # division elif len(edges) == 2: # get both edges @@ -100,7 +99,6 @@ def write_ctc(graph, start_frame, end_frame, shape, # inc trackID track_cntr += 1 - # curr_cells.remove(e[0]) if gt: for e in edges: @@ -114,7 +112,6 @@ def write_ctc(graph, start_frame, end_frame, shape, for d in ['z', 'y', 'x']]), np.array([dataSt[d] - dataNd[d] for d in ['z', 'y', 'x']]))) - prev_cells = set(graph.cells_by_frame(f)) tracks = {} @@ -124,7 +121,7 @@ def write_ctc(graph, start_frame, end_frame, shape, else: tracks[v[0]] = (v[1], [v[2]]) - if not gt: + if not gt and not "unedited" in out_dir: cells_by_t_data = { t: [ ( @@ -137,12 +134,13 @@ def write_ctc(graph, start_frame, end_frame, shape, ] for t in range(start_frame, end_frame) } - with open(os.path.join(out_dir, "parent_vectors.txt"), 'w') as of: - for t, cs in cells_by_t_data.items(): - for c in cs: - of.write("{} {} {} {} {} {} {} {} {}\n".format( - t, c[0], node_to_track[c[0]][0], c[1][0], c[1][1], c[1][2], - c[2][0], c[2][1], c[2][2])) + with open(os.path.join(out_dir, "parent_vectors.txt"), 'w') as of: + for t, cs in cells_by_t_data.items(): + for c in cs: + of.write("{} {} {} {} {} {} {} {} {}\n".format( + t, c[0], node_to_track[c[0]][0], + c[1][0], c[1][1], c[1][2], + c[2][0], c[2][1], c[2][2])) with open(os.path.join(out_dir, txt_fn), 'w') as of: for t, v in tracks.items(): @@ -153,40 +151,49 @@ def write_ctc(graph, start_frame, end_frame, shape, if paint_sphere: spheres = {} - radii = {30: 35, - 60: 25, - 100: 15, - 1000: 11, - } - radii = {30: 71, - 60: 51, - 100: 31, - 1000: 21, - } - radii = {15: 71, - 30: 61, - 60: 61, - 90: 51, - 120: 31, - 1000: 21, - } - - for th, r in radii.items(): - sphere_shape = (max(3, r//voxel_size[1]+1), r, r) - zh = sphere_shape[0]//2 - yh = sphere_shape[1]//2 - xh = sphere_shape[2]//2 - sphere_rad = (sphere_shape[0]/2, - sphere_shape[1]/2, - sphere_shape[2]/2) - sphere = rg.ellipsoid(sphere_shape, sphere_rad) - spheres[th] = [sphere, zh, yh, xh] + + if radii is not None: + for th, r in radii.items(): + sphere_shape = (max(3, r//voxel_size[1]+1), r, r) + zh = sphere_shape[0]//2 + yh = sphere_shape[1]//2 + xh = sphere_shape[2]//2 + sphere_rad = (sphere_shape[0]/2, + sphere_shape[1]/2, + sphere_shape[2]/2) + sphere = rg.ellipsoid(sphere_shape, sphere_rad) + spheres[th] = [sphere, zh, yh, xh] + else: + for f in range(start_frame, end_frame): + for c, v in node_to_track.items(): + if f != v[2]: + continue + rt = int(graph.nodes[c]['r']) + if rt in spheres: + continue + rz = max(5, rt*2//voxel_size[1]+1) + r = rt * 2 - 1 + if rz % 2 == 0: + rz -= 1 + sphere_shape = (rz, r, r) + zh = sphere_shape[0]//2 + yh = sphere_shape[1]//2 + xh = sphere_shape[2]//2 + sphere_rad = (sphere_shape[0]//2, + sphere_shape[1]//2, + sphere_shape[2]//2) + sphere = rg.ellipsoid(sphere_shape, sphere_rad) + logger.debug("%s %s %s %s %s %s %s", rz, r*2//voxel_size[1]+1, + graph.nodes[c]['r'], r, sphere.shape, + sphere_shape, [sphere.shape, zh, yh, xh]) + spheres[rt] = [sphere, zh, yh, xh] + for f in range(start_frame, end_frame): logger.info("Processing frame %d" % f) arr = np.zeros(shape[1:], dtype=np.uint16) if surface is not None: fg = (surface[f] > fg_threshold).astype(np.uint8) - if mask: + if mask is not None: fg *= mask for c, v in node_to_track.items(): @@ -199,11 +206,14 @@ def write_ctc(graph, start_frame, end_frame, shape, logger.debug("%s %s %s %s %s %s %s %s", v[0], f, t, z, y, x, c, v) if paint_sphere: - if isinstance(spheres, dict): + if radii is not None: for th in sorted(spheres.keys()): if t < int(th): sphere, zh, yh, xh = spheres[th] break + else: + r = int(graph.nodes[c]['r']) + sphere, zh, yh, xh = spheres[r] try: arr[(z-zh):(z+zh+1), (y-yh):(y+yh+1), @@ -265,19 +275,15 @@ def write_ctc(graph, start_frame, end_frame, shape, xx1:xx2] = sphereT * v[0] # raise e else: - arr[z, y, x] = v[0] + if gt: + for zd in range(-1, 2): + for yd in range(-2, 3): + for xd in range(-2, 3): + arr[z+zd, y+yd, x+xd] = v[0] + else: + arr[z, y, x] = v[0] if surface is not None: - radii = {10000: 12, - } - # radii = { - # 30: 20, - # 70: 15, - # 100: 13, - # 130: 11, - # 180: 10, - # 270: 8, - # 9999: 7, - # } + radii = radii if radii is not None else {10000: 12} for th in sorted(radii.keys()): if f < th: d = radii[th] @@ -294,31 +300,56 @@ def write_ctc(graph, start_frame, end_frame, shape, val = tmp[tuple(n)] tmp = tmp == val - # for v in np.argwhere(tmp != 0): - # if np.linalg.norm(n-v) < d: - # arr_tmp[tuple(v)] = u - vs = np.argwhere(tmp != 0) vss = np.copy(vs) - vss[:, 0] *= 5 - n[0] *= 5 + vss[:, 0] *= voxel_size[1] + n[0] *= voxel_size[1] tmp2 = np.argwhere(np.linalg.norm(n-vss, axis=1) < d) assert len(tmp2) > 0,\ "no pixel found {} {} {} {}".format(f, d, n, val) for v in tmp2: arr_tmp[tuple(vs[v][0])] = u arr = arr_tmp + + + if paint_sphere: + for c, v in node_to_track.items(): + if f != v[2]: + continue + t = graph.nodes[c]['t'] + z = int(graph.nodes[c]['z']/voxel_size[1]) + y = int(graph.nodes[c]['y']/voxel_size[2]) + x = int(graph.nodes[c]['x']/voxel_size[3]) + r = int(graph.nodes[c]['r']) + for zd in range(-1, 2): + for yd in range(-2, 3): + for xd in range(-2, 3): + arr[z+zd, y+yd, x+xd] = v[0] + if paint_sphere: + for c, v in node_to_track.items(): + if (v[0] == 30217): + t = graph.nodes[c]['t'] + z = int(graph.nodes[c]['z']/voxel_size[1])+1 + y = int(graph.nodes[c]['y']/voxel_size[2])+2 + x = int(graph.nodes[c]['x']/voxel_size[3])+2 + r = int(graph.nodes[c]['r']) + for zd in range(-1, 2): + for yd in range(-2, 3): + for xd in range(-2, 3): + arr[z+zd, y+yd, x+xd] = v[0] + if (v[0] == 33721): + t = graph.nodes[c]['t'] + z = int(graph.nodes[c]['z']/voxel_size[1])-1 + y = int(graph.nodes[c]['y']/voxel_size[2])-2 + x = int(graph.nodes[c]['x']/voxel_size[3])-2 + r = int(graph.nodes[c]['r']) + for zd in range(-1, 2): + for yd in range(-2, 3): + for xd in range(-2, 3): + arr[z+zd, y+yd, x+xd] = v[0] + logger.info("Writing tiff tile for frame %d" % f) tifffile.imwrite(os.path.join( out_dir, tif_fn.format(f)), arr, compress=3) - # tifffile.imwrite(os.path.join( - # out_dir, "ws" + tif_fn.format(f)), arr2, - # compress=3) - # tifffile.imwrite(os.path.join( - # out_dir, "surf" + tif_fn.format(f)), surface[f], - # compress=3) - # tifffile.imwrite(os.path.join( - # out_dir, "fg" + tif_fn.format(f)), fg, - # compress=3) From dab11791a9cfe58c43773dc1b66a4ff956db69fe Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:31:12 -0400 Subject: [PATCH 182/263] tracking: fix typo and add some debug logging --- linajea/tracking/non_minimal_track.py | 6 +++--- linajea/tracking/solver.py | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/linajea/tracking/non_minimal_track.py b/linajea/tracking/non_minimal_track.py index 09520d9..b49d96a 100644 --- a/linajea/tracking/non_minimal_track.py +++ b/linajea/tracking/non_minimal_track.py @@ -51,9 +51,9 @@ def nm_track(graph, config, selected_key, frame_key='t', frames=None): else None for p in config.solve.parameters] if any(use_cell_state): - assert None not in use_cell_state, - ("mixture of with and without use_cell_state in concurrent " - "solving not supported yet") + assert None not in use_cell_state, \ + ("mixture of with and without use_cell_state in concurrent " + "solving not supported yet") parameters = config.solve.parameters if not isinstance(selected_key, list): diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index 2938d46..065ee56 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -61,6 +61,9 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, self.pin_constraints = [] # list of LinearConstraint objects self.solver = None + logger.debug("cell cycle key? %s", parameters.cell_cycle_key) + logger.debug("write ssvm? %s", self.write_struct_svm) + self._create_indicators() self._create_solver() self._create_constraints() @@ -724,6 +727,7 @@ def _add_cell_cycle_constraints(self): node_cell_cycle_constraint_file.close() def _add_node_density_constraints_objective(self): + logger.debug("adding cell density constraints") from scipy.spatial import cKDTree import numpy as np try: From 8e82bcb5406bf8ebd6b26151b6337f6bd49c046f Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:43:07 -0400 Subject: [PATCH 183/263] solve_blockwise: cleanup and some changes for greedy tracking --- linajea/process_blockwise/solve_blockwise.py | 27 ++++++-------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index ca5d36f..a7a3864 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -35,9 +35,12 @@ def solve_blockwise(linajea_config): graph_provider.database.drop_collection( 'solve_' + str(hash(frozenset(parameters_id))) + '_daisy') - block_write_roi = daisy.Roi( - (0, 0, 0, 0), - block_size) + if linajea_config.solve.greedy: + block_write_roi = solve_roi + else: + block_write_roi = daisy.Roi( + (0, 0, 0, 0), + block_size) block_read_roi = block_write_roi.grow( context, context) @@ -128,21 +131,7 @@ def solve_in_block(linajea_config, else: _id = hash(frozenset(parameters_id)) step_name = 'solve_' + str(_id) - logger.debug("Solving in block %s", block) - - done_indices = [] - for index, pid in enumerate(parameters_id): - name = 'solve_' + str(pid) - if check_function_all_blocks(name, db_name, db_host): - logger.info("Params with id %d already completed. Removing", pid) - done_indices.append(index) - for index in done_indices[::-1]: - del parameters_id[index] - del parameters[index] - - if len(parameters) == 0: - logger.info("All parameters already completed. Exiting") - return 0 + logger.info("Solving in block %s", block) if solution_roi: # Limit block to source_roi @@ -205,7 +194,7 @@ def solve_in_block(linajea_config, frames = [read_roi.get_offset()[0], read_roi.get_offset()[0] + read_roi.get_shape()[0]] if linajea_config.solve.greedy: - greedy_track(graph, selected_keys[0], + greedy_track(graph=graph, selected_key=selected_keys[0], cell_indicator_threshold=0.2) elif linajea_config.solve.non_minimal: nm_track(graph, linajea_config, selected_keys, frames=frames) From e30a7d4dde8503cbe5cfd2b380de46ee644b72bd Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 8 Jun 2022 12:45:23 -0400 Subject: [PATCH 184/263] eval: add func to get score for set of parameters --- linajea/evaluation/__init__.py | 4 +++- linajea/evaluation/analyze_results.py | 26 +++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/linajea/evaluation/__init__.py b/linajea/evaluation/__init__.py index 4abbd4c..38feda2 100644 --- a/linajea/evaluation/__init__.py +++ b/linajea/evaluation/__init__.py @@ -3,11 +3,13 @@ from .match import match_edges from .match_nodes import match_nodes from .evaluate_setup import evaluate_setup +from .evaluator import Evaluator from .report import Report from .validation_metric import validation_score from .analyze_results import ( get_result, get_results, get_best_result, - get_results_sorted, get_best_result_with_config, get_result_id, get_results_sorted_db, + get_results_sorted, get_best_result_with_config, + get_result_id, get_result_params, get_results_sorted_db, get_best_result_per_setup, get_tgmm_results, get_best_tgmm_result, diff --git a/linajea/evaluation/analyze_results.py b/linajea/evaluation/analyze_results.py index 09ad0cf..f43c425 100644 --- a/linajea/evaluation/analyze_results.py +++ b/linajea/evaluation/analyze_results.py @@ -278,14 +278,34 @@ def get_result_id( config, parameters_id): ''' Get the scores, statistics, and parameters for given - setup, region, and parameters. + config and parameters_id. Returns a dictionary containing the keys and values of the score object. - - tracking_parameters can be a dict or a TrackingParameters object''' + ''' db_name = config.inference.data_source.db_name candidate_db = CandidateDatabase(db_name, config.general.db_host, 'r') result = candidate_db.get_score(parameters_id, eval_params=config.evaluate.parameters) return result + + +def get_result_params( + config, + parameters): + ''' Get the scores and statistics for a given config and set of + parameters. + Returns a dictionary containing the keys and values of the score + object. + ''' + db_name = config.inference.data_source.db_name + candidate_db = CandidateDatabase(db_name, config.general.db_host, 'r') + if config.evaluate.parameters.roi is None: + config.evaluate.parameters.roi = config.inference.data_source.roi + + result = candidate_db.get_score( + candidate_db.get_parameters_id_round( + parameters, + fail_if_not_exists=True), + eval_params=config.evaluate.parameters) + return result From ad28e63eb4d8d3c6871ae3f8b729ecec27c6305c Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 05:32:35 -0400 Subject: [PATCH 185/263] rename gunpowder nodes folder to gunpowder_nodes --- linajea/{gunpowder => gunpowder_nodes}/__init__.py | 0 .../add_parent_vectors.py | 13 +++---------- .../combine_channels.py | 0 .../{gunpowder => gunpowder_nodes}/get_labels.py | 0 linajea/{gunpowder => gunpowder_nodes}/no_op.py | 0 linajea/{gunpowder => gunpowder_nodes}/normalize.py | 0 .../random_location_exclude_time.py | 4 ++-- linajea/{gunpowder => gunpowder_nodes}/set_flag.py | 0 .../{gunpowder => gunpowder_nodes}/shift_augment.py | 4 ++-- .../shuffle_channels.py | 0 .../{gunpowder => gunpowder_nodes}/tracks_source.py | 2 +- .../{gunpowder => gunpowder_nodes}/write_cells.py | 0 12 files changed, 8 insertions(+), 15 deletions(-) rename linajea/{gunpowder => gunpowder_nodes}/__init__.py (100%) rename linajea/{gunpowder => gunpowder_nodes}/add_parent_vectors.py (95%) rename linajea/{gunpowder => gunpowder_nodes}/combine_channels.py (100%) rename linajea/{gunpowder => gunpowder_nodes}/get_labels.py (100%) rename linajea/{gunpowder => gunpowder_nodes}/no_op.py (100%) rename linajea/{gunpowder => gunpowder_nodes}/normalize.py (100%) rename linajea/{gunpowder => gunpowder_nodes}/random_location_exclude_time.py (93%) rename linajea/{gunpowder => gunpowder_nodes}/set_flag.py (100%) rename linajea/{gunpowder => gunpowder_nodes}/shift_augment.py (99%) rename linajea/{gunpowder => gunpowder_nodes}/shuffle_channels.py (100%) rename linajea/{gunpowder => gunpowder_nodes}/tracks_source.py (99%) rename linajea/{gunpowder => gunpowder_nodes}/write_cells.py (100%) diff --git a/linajea/gunpowder/__init__.py b/linajea/gunpowder_nodes/__init__.py similarity index 100% rename from linajea/gunpowder/__init__.py rename to linajea/gunpowder_nodes/__init__.py diff --git a/linajea/gunpowder/add_parent_vectors.py b/linajea/gunpowder_nodes/add_parent_vectors.py similarity index 95% rename from linajea/gunpowder/add_parent_vectors.py rename to linajea/gunpowder_nodes/add_parent_vectors.py index 899bd8e..4cdff28 100644 --- a/linajea/gunpowder/add_parent_vectors.py +++ b/linajea/gunpowder_nodes/add_parent_vectors.py @@ -16,8 +16,7 @@ class AddParentVectors(BatchFilter): def __init__( self, points, array, mask, radius, - move_radius=0, array_spec=None, dense=False, - subsampling=None): + move_radius=0, array_spec=None, dense=False): self.points = points self.array = array @@ -30,7 +29,6 @@ def __init__( self.array_spec = array_spec self.dense = dense - self.subsampling = subsampling def setup(self): @@ -182,11 +180,6 @@ def __draw_parent_vectors( empty = True cnt = 0 total = 0 - for point_id, point in points.data.items(): - if self.subsampling is not None and point.value > self.subsampling: - logger.debug("skipping point %s %s due to subsampling %s", - point, point.value, self.subsampling) - continue avg_radius = [] for point in points.nodes: @@ -246,7 +239,7 @@ def __draw_parent_vectors( z_min:z_max+1, y_min:y_max+1, x_min:x_max+1] - logger.info("mask cut %s", mask_cut.shape) + logger.debug("mask cut %s", mask_cut.shape) point_mask = np.zeros(shape, dtype=bool) @@ -326,6 +319,6 @@ def __draw_parent_vectors( if empty: logger.warning("No parent vectors written for points %s" % points.nodes) - logger.info("written {}/{}".format(cnt, total)) + logger.debug("written {}/{}".format(cnt, total)) return parent_vectors, mask.astype(np.float32) diff --git a/linajea/gunpowder/combine_channels.py b/linajea/gunpowder_nodes/combine_channels.py similarity index 100% rename from linajea/gunpowder/combine_channels.py rename to linajea/gunpowder_nodes/combine_channels.py diff --git a/linajea/gunpowder/get_labels.py b/linajea/gunpowder_nodes/get_labels.py similarity index 100% rename from linajea/gunpowder/get_labels.py rename to linajea/gunpowder_nodes/get_labels.py diff --git a/linajea/gunpowder/no_op.py b/linajea/gunpowder_nodes/no_op.py similarity index 100% rename from linajea/gunpowder/no_op.py rename to linajea/gunpowder_nodes/no_op.py diff --git a/linajea/gunpowder/normalize.py b/linajea/gunpowder_nodes/normalize.py similarity index 100% rename from linajea/gunpowder/normalize.py rename to linajea/gunpowder_nodes/normalize.py diff --git a/linajea/gunpowder/random_location_exclude_time.py b/linajea/gunpowder_nodes/random_location_exclude_time.py similarity index 93% rename from linajea/gunpowder/random_location_exclude_time.py rename to linajea/gunpowder_nodes/random_location_exclude_time.py index 52d2440..274ddd7 100644 --- a/linajea/gunpowder/random_location_exclude_time.py +++ b/linajea/gunpowder_nodes/random_location_exclude_time.py @@ -16,14 +16,14 @@ def __init__( mask=None, ensure_nonempty=None, p_nonempty=1.0, - subsampling=None): + point_balance_radius=1): super(RandomLocationExcludeTime, self).__init__( min_masked, mask, ensure_nonempty, p_nonempty, - subsampling=subsampling) + point_balance_radius=point_balance_radius) self.raw = raw if isinstance(time_interval, list) and \ diff --git a/linajea/gunpowder/set_flag.py b/linajea/gunpowder_nodes/set_flag.py similarity index 100% rename from linajea/gunpowder/set_flag.py rename to linajea/gunpowder_nodes/set_flag.py diff --git a/linajea/gunpowder/shift_augment.py b/linajea/gunpowder_nodes/shift_augment.py similarity index 99% rename from linajea/gunpowder/shift_augment.py rename to linajea/gunpowder_nodes/shift_augment.py index 5d79f6b..4856e66 100644 --- a/linajea/gunpowder/shift_augment.py +++ b/linajea/gunpowder_nodes/shift_augment.py @@ -242,7 +242,7 @@ def shift_points( """ nodes = list(points.nodes) - logger.info("nodes before %s", nodes) + logger.debug("nodes before %s", nodes) spec = points.spec shift_axis_start_pos = spec.roi.get_offset()[shift_axis] @@ -258,7 +258,7 @@ def shift_points( if not request_roi.contains(loc): points.remove_node(node) - logger.info("nodes after %s", nodes) + logger.debug("nodes after %s", nodes) points.spec.roi = request_roi return points diff --git a/linajea/gunpowder/shuffle_channels.py b/linajea/gunpowder_nodes/shuffle_channels.py similarity index 100% rename from linajea/gunpowder/shuffle_channels.py rename to linajea/gunpowder_nodes/shuffle_channels.py diff --git a/linajea/gunpowder/tracks_source.py b/linajea/gunpowder_nodes/tracks_source.py similarity index 99% rename from linajea/gunpowder/tracks_source.py rename to linajea/gunpowder_nodes/tracks_source.py index 9aac935..016226b 100644 --- a/linajea/gunpowder/tracks_source.py +++ b/linajea/gunpowder_nodes/tracks_source.py @@ -4,7 +4,7 @@ import numpy as np import logging -from linajea import parse_tracks_file +from linajea.utils import parse_tracks_file logger = logging.getLogger(__name__) diff --git a/linajea/gunpowder/write_cells.py b/linajea/gunpowder_nodes/write_cells.py similarity index 100% rename from linajea/gunpowder/write_cells.py rename to linajea/gunpowder_nodes/write_cells.py From a2db2cfd9b2dee668b4a48045117d23a875ff0fc Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 05:33:15 -0400 Subject: [PATCH 186/263] remove tensorflow network code (moved to sep. repo) --- linajea/tensorflow/README | 10 - linajea/tensorflow/__init__.py | 3 - linajea/tensorflow/conv4d.py | 246 ---------------------- linajea/tensorflow/unet.py | 360 --------------------------------- 4 files changed, 619 deletions(-) delete mode 100644 linajea/tensorflow/README delete mode 100644 linajea/tensorflow/__init__.py delete mode 100644 linajea/tensorflow/conv4d.py delete mode 100644 linajea/tensorflow/unet.py diff --git a/linajea/tensorflow/README b/linajea/tensorflow/README deleted file mode 100644 index b5ffbb2..0000000 --- a/linajea/tensorflow/README +++ /dev/null @@ -1,10 +0,0 @@ -README: Tensorflow module - -This is a deprecated module, having been replaced by -funlib.learn.tensorflow. Setups older than setup10 -used the linajea.tensorflow U-Net in their mknet.py, -and setup10 and more recent use funlib.learn.tensorflow. - -Other users who have modeled their setups after older -setups are encouraged to switch to the funlib U-Net -used in setup10+. diff --git a/linajea/tensorflow/__init__.py b/linajea/tensorflow/__init__.py deleted file mode 100644 index 88b99e5..0000000 --- a/linajea/tensorflow/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# flake8: noqa -from __future__ import absolute_import -from .unet import unet, conv_pass diff --git a/linajea/tensorflow/conv4d.py b/linajea/tensorflow/conv4d.py deleted file mode 100644 index 3bb8031..0000000 --- a/linajea/tensorflow/conv4d.py +++ /dev/null @@ -1,246 +0,0 @@ -# -*- coding: UTF-8 -*- -import tensorflow as tf - - -def conv4d( - inputs, - filters, - kernel_size, - strides=(1, 1, 1, 1), - padding='valid', - data_format='channels_last', - dilation_rate=(1, 1, 1, 1), - activation=None, - use_bias=True, - kernel_initializer=None, - bias_initializer=tf.zeros_initializer(), - kernel_regularizer=None, - bias_regularizer=None, - activity_regularizer=None, - trainable=True, - name=None, - reuse=None): - '''Performs a 4D convolution of the ``(t, z, y, x)`` dimensions of a tensor - with shape ``(b, c, l, d, h, w)`` with ``k`` filters. The output tensor - will be of shape ``(b, k, l', d', h', w')``. ``(l', d', h', w')`` will be - smaller than ``(l, d, h, w)`` if a ``valid`` padding was chosen. - - This operator realizes a 4D convolution by performing several 3D - convolutions. The following example demonstrates how this works for a 2D - convolution as a sequence of 1D convolutions:: - - I.shape == (h, w) - k.shape == (U, V) and U%2 = V%2 = 1 - - # we assume kernel is indexed as follows: - u in [-U/2,...,U/2] - v in [-V/2,...,V/2] - - (k*I)[i,j] = Σ_u Σ_v k[u,v] I[i+u,j+v] - = Σ_u (k[u]*I[i+u])[j] - (k*I)[i] = Σ_u k[u]*I[i+u] - (k*I) = Σ_u k[u]*I_u, with I_u[i] = I[i+u] shifted I by u - - Example: - - I = [ - [0,0,0], - [1,1,1], - [1,1,0], - [1,0,0], - [0,0,1] - ] - - k = [ - [1,1,1], - [1,2,1], - [1,1,3] - ] - - # convolve every row in I with every row in k, comments show output - # row the convolution contributes to - (I*k[0]) = [ - [0,0,0], # I[0] with k[0] ⇒ (k*I)[ 1] ✔ - [2,3,2], # I[1] with k[0] ⇒ (k*I)[ 2] ✔ - [2,2,1], # I[2] with k[0] ⇒ (k*I)[ 3] ✔ - [1,1,0], # I[3] with k[0] ⇒ (k*I)[ 4] ✔ - [0,1,1] # I[4] with k[0] ⇒ (k*I)[ 5] - ] - (I*k[1]) = [ - [0,0,0], # I[0] with k[1] ⇒ (k*I)[ 0] ✔ - [3,4,3], # I[1] with k[1] ⇒ (k*I)[ 1] ✔ - [3,3,1], # I[2] with k[1] ⇒ (k*I)[ 2] ✔ - [2,1,0], # I[3] with k[1] ⇒ (k*I)[ 3] ✔ - [0,1,2] # I[4] with k[1] ⇒ (k*I)[ 4] ✔ - ] - (I*k[2]) = [ - [0,0,0], # I[0] with k[2] ⇒ (k*I)[-1] - [4,5,2], # I[1] with k[2] ⇒ (k*I)[ 0] ✔ - [4,2,1], # I[2] with k[2] ⇒ (k*I)[ 1] ✔ - [1,1,0], # I[3] with k[2] ⇒ (k*I)[ 2] ✔ - [0,3,1] # I[4] with k[2] ⇒ (k*I)[ 3] ✔ - ] - - # the sum of all valid output rows gives k*I (here shown for row 2) - (k*I)[2] = ( - [2,3,2] + - [3,3,1] + - [1,1,0] + - ) = [6,7,3] - ''' - - # check arguments - assert len(inputs.get_shape().as_list()) == 6, ( - "Tensor of shape (b, c, l, d, h, w) expected") - assert isinstance(kernel_size, int) or len(kernel_size) == 4, ( - "kernel size should be an integer or a 4D tuple") - assert strides == (1, 1, 1, 1), ( - "Strides other than 1 not yet implemented") - assert data_format == 'channels_first', ( - "Data format other than 'channels_first' not yet implemented") - assert dilation_rate == (1, 1, 1, 1), ( - "Dilation rate other than 1 not yet implemented") - - if not name: - name = 'conv4d' - - # input, kernel, and output sizes - (b, c_i, l_i, d_i, h_i, w_i) = tuple(inputs.get_shape().as_list()) - if isinstance(kernel_size, int): - (l_k, d_k, h_k, w_k) = (kernel_size,)*4 - else: - (l_k, d_k, h_k, w_k) = kernel_size - - # output size for 'valid' convolution - if padding == 'valid': - (l_o, _, _, _) = ( - l_i - l_k + 1, - d_i - d_k + 1, - h_i - h_k + 1, - w_i - w_k + 1 - ) - else: - (l_o, _, _, _) = (l_i, d_i, h_i, w_i) - - # output tensors for each 3D frame - frame_results = [None]*l_o - - # convolve each kernel frame i with each input frame j - for i in range(l_k): - - # reuse variables of previous 3D convolutions for the same kernel - # frame (or if the user indicated to have all variables reused) - reuse_kernel = reuse - - for j in range(l_i): - - # add results to this output frame - out_frame = j - (i - l_k//2) - (l_i - l_o)//2 - if out_frame < 0 or out_frame >= l_o: - continue - - # convolve input frame j with kernel frame i - frame_conv3d = tf.layers.conv3d( - tf.reshape(inputs[:, :, j, :], (b, c_i, d_i, h_i, w_i)), - filters, - kernel_size=(d_k, h_k, w_k), - padding=padding, - data_format='channels_first', - activation=None, - use_bias=use_bias, - kernel_initializer=kernel_initializer, - bias_initializer=bias_initializer, - kernel_regularizer=kernel_regularizer, - bias_regularizer=bias_regularizer, - activity_regularizer=activity_regularizer, - trainable=trainable, - name=name + '_3dchan%d' % i, - reuse=reuse_kernel) - - # subsequent frame convolutions should use the same kernel - reuse_kernel = True - - if frame_results[out_frame] is None: - frame_results[out_frame] = frame_conv3d - else: - frame_results[out_frame] += frame_conv3d - - output = tf.stack(frame_results, axis=2) - - if activation: - output = activation(output) - - return output - - -if __name__ == "__main__": - - import numpy as np - - i = np.round(np.random.random((1, 1, 10, 11, 12, 13))*100) - inputs = tf.constant(i, dtype=tf.float32) - bias_init = tf.constant_initializer(0) - - output = conv4d( - inputs, - 1, - (3, 3, 3, 3), - data_format='channels_first', - bias_initializer=bias_init, - name='conv4d_valid') - - with tf.Session() as s: - - s.run(tf.global_variables_initializer()) - o = s.run(output) - - k0 = tf.get_default_graph().get_tensor_by_name( - 'conv4d_valid_3dchan0/kernel:0').eval().flatten() - k1 = tf.get_default_graph().get_tensor_by_name( - 'conv4d_valid_3dchan1/kernel:0').eval().flatten() - k2 = tf.get_default_graph().get_tensor_by_name( - 'conv4d_valid_3dchan2/kernel:0').eval().flatten() - - print("conv4d at (0, 0, 0, 0): %s" % o[0, 0, 0, 0, 0, 0]) - i0 = i[0, 0, 0, 0:3, 0:3, 0:3].flatten() - i1 = i[0, 0, 1, 0:3, 0:3, 0:3].flatten() - i2 = i[0, 0, 2, 0:3, 0:3, 0:3].flatten() - - compare = (i0*k0 + i1*k1 + i2*k2).sum() - print("manually computed value at (0, 0, 0, 0): %s" % compare) - - print("conv4d at (4, 4, 4, 4): %s" - % o[0, 0, 4, 4, 4, 4]) - i0 = i[0, 0, 4, 4:7, 4:7, 4:7].flatten() - i1 = i[0, 0, 5, 4:7, 4:7, 4:7].flatten() - i2 = i[0, 0, 6, 4:7, 4:7, 4:7].flatten() - - compare = (i0*k0 + i1*k1 + i2*k2).sum() - print("manually computed value at (4, 4, 4, 4): %s" % compare) - - output = conv4d( - inputs, - 1, - (3, 3, 3, 3), - data_format='channels_first', - padding='same', - kernel_initializer=tf.constant_initializer(1), - bias_initializer=bias_init, - name='conv4d_same') - - with tf.Session() as s: - - s.run(tf.global_variables_initializer()) - o = s.run(output) - - print("conv4d at (0, 0, 0, 0): %s" % o[0, 0, 0, 0, 0, 0]) - i0 = i[0, 0, 0:2, 0:2, 0:2, 0:2] - print("manually computed value at (0, 0, 0, 0): %s" % i0.sum()) - - print("conv4d at (5, 5, 5, 5): %s" % o[0, 0, 5, 5, 5, 5]) - i5 = i[0, 0, 4:7, 4:7, 4:7, 4:7] - print("manually computed value at (5, 5, 5, 5): %s" % i5.sum()) - - print("conv4d at (9, 10, 11, 12): %s" % o[0, 0, 9, 10, 11, 12]) - i9 = i[0, 0, 8:, 9:, 10:, 11:] - print("manually computed value at (9, 10, 11, 12): %s" % i9.sum()) diff --git a/linajea/tensorflow/unet.py b/linajea/tensorflow/unet.py deleted file mode 100644 index 5bbc61b..0000000 --- a/linajea/tensorflow/unet.py +++ /dev/null @@ -1,360 +0,0 @@ -import tensorflow as tf -from .conv4d import conv4d - - -def conv_pass( - fmaps_in, - kernel_size, - num_fmaps, - num_repetitions, - activation='relu', - name='conv_pass'): - '''Create a convolution pass:: - - f_in --> f_1 --> ... --> f_n - - where each ``-->`` is a convolution followed by a (non-linear) activation - function and ``n`` ``num_repetitions``. Each convolution will decrease the - size of the feature maps by ``kernel_size-1``. - - Args: - - f_in: - - The input tensor of shape ``(batch_size, channels, [length,] depth, - height, width)``. - - kernel_size: - - Size of the kernel. Forwarded to tf.layers.conv3d. - - num_fmaps: - - The number of feature maps to produce with each convolution. - - num_repetitions: - - How many convolutions to apply. - - activation: - - Which activation to use after a convolution. Accepts the name of a - tensorflow activation function (e.g., ``relu`` for ``tf.nn.relu``). - - ''' - - fmaps = fmaps_in - if activation is not None: - activation = getattr(tf.nn, activation) - - for i in range(num_repetitions): - - in_shape = tuple(fmaps.get_shape().as_list()) - - if len(in_shape) == 6: - conv_op = conv4d - elif len(in_shape) == 5: - conv_op = tf.layers.conv3d - else: - raise RuntimeError( - "Input tensor of shape %s not supported" % (in_shape,)) - - fmaps = conv_op( - inputs=fmaps, - filters=num_fmaps, - kernel_size=kernel_size, - padding='valid', - data_format='channels_first', - activation=activation, - name=name + '_%i' % i) - - out_shape = tuple(fmaps.get_shape().as_list()) - - # eliminate t dimension if length is 1 - if len(out_shape) == 6: - length = out_shape[2] - if length == 1: - out_shape = out_shape[0:2] + out_shape[3:] - fmaps = tf.reshape(fmaps, out_shape) - - return fmaps - - -def downsample(fmaps_in, factors, name='down'): - - in_shape = fmaps_in.get_shape().as_list() - is_4d = len(in_shape) == 6 - - if is_4d: - - # store time dimension in channels - fmaps_in = tf.reshape(fmaps_in, ( - in_shape[0], - in_shape[1]*in_shape[2], - in_shape[3], - in_shape[4], - in_shape[5])) - - fmaps = tf.layers.max_pooling3d( - fmaps_in, - pool_size=factors, - strides=factors, - padding='valid', - data_format='channels_first', - name=name) - - if is_4d: - - out_shape = fmaps.get_shape().as_list() - - # restore time dimension - fmaps = tf.reshape(fmaps, ( - in_shape[0], - in_shape[1], - in_shape[2], - out_shape[2], - out_shape[3], - out_shape[4])) - - return fmaps - - -def upsample(fmaps_in, factors, num_fmaps, activation='relu', name='up'): - - if activation is not None: - activation = getattr(tf.nn, activation) - - fmaps = tf.layers.conv3d_transpose( - fmaps_in, - filters=num_fmaps, - kernel_size=factors, - strides=factors, - padding='valid', - data_format='channels_first', - activation=activation, - name=name) - - return fmaps - - -def crop_tzyx(fmaps_in, shape): - '''Crop spatial and time dimensions to match shape. - - Args: - - fmaps_in: - - The input tensor of shape ``(b, c, z, y, x)`` (for 3D) or ``(b, c, - t, z, y, x)`` (for 4D). - - shape: - - A list (not a tensor) with the requested shape ``[_, _, z, y, x]`` - (for 3D) or ``[_, _, t, z, y, x]`` (for 4D). - ''' - - in_shape = fmaps_in.get_shape().as_list() - - in_is_4d = len(in_shape) == 6 - out_is_4d = len(shape) == 6 - - if in_is_4d and not out_is_4d: - # set output shape for time to 1 - shape = shape[0:2] + [1] + shape[2:] - - if in_is_4d: - offset = [ - 0, # batch - 0, # channel - (in_shape[2] - shape[2])//2, # t - (in_shape[3] - shape[3])//2, # z - (in_shape[4] - shape[4])//2, # y - (in_shape[5] - shape[5])//2, # x - ] - size = [ - in_shape[0], - in_shape[1], - shape[2], - shape[3], - shape[4], - shape[5], - ] - else: - offset = [ - 0, # batch - 0, # channel - (in_shape[2] - shape[2])//2, # z - (in_shape[3] - shape[3])//2, # y - (in_shape[4] - shape[4])//2, # x - ] - size = [ - in_shape[0], - in_shape[1], - shape[2], - shape[3], - shape[4], - ] - - fmaps = tf.slice(fmaps_in, offset, size) - - if in_is_4d and not out_is_4d: - # remove time dimension - shape = shape[0:2] + shape[3:] - fmaps = tf.reshape(fmaps, shape) - - return fmaps - - -def unet( - fmaps_in, - num_fmaps, - fmap_inc_factor, - downsample_factors, - activation='relu', - layer=0): - '''Create a U-Net:: - - f_in --> f_left --------------------------->> f_right--> f_out - | ^ - v | - g_in --> g_left ------->> g_right --> g_out - | ^ - v | - ... - - where each ``-->`` is a convolution pass (see ``conv_pass``), each `-->>` a - crop, and down and up arrows are max-pooling and transposed convolutions, - respectively. - - The U-Net expects 3D or 4D tensors shaped like:: - - ``(batch=1, channels, [length,] depth, height, width)``. - - This U-Net performs only "valid" convolutions, i.e., sizes of the feature - maps decrease after each convolution. It will perfrom 4D convolutions as - long as ``length`` is greater than 1. As soon as ``length`` is 1 due to a - valid convolution, the time dimension will be dropped and tensors with - ``(b, c, z, y, x)`` will be use (and returned) from there on. - - Args: - - fmaps_in: - - The input tensor. - - num_fmaps: - - The number of feature maps in the first layer. This is also the - number of output feature maps. Stored in the ``channels`` - dimension. - - fmap_inc_factor: - - By how much to multiply the number of feature maps between layers. - If layer 0 has ``k`` feature maps, layer ``l`` will have - ``k*fmap_inc_factor**l``. - - downsample_factors: - - List of lists ``[z, y, x]`` to use to down- and up-sample the - feature maps between layers. - - activation: - - Which activation to use after a convolution. Accepts the name of a - tensorflow activation function (e.g., ``relu`` for ``tf.nn.relu``). - - layer: - - Used internally to build the U-Net recursively. - ''' - - prefix = " "*layer - print(prefix + "Creating U-Net layer %i" % layer) - print(prefix + "f_in: " + str(fmaps_in.shape)) - - # convolve - f_left = conv_pass( - fmaps_in, - kernel_size=3, - num_fmaps=num_fmaps, - num_repetitions=2, - activation=activation, - name='unet_layer_%i_left' % layer) - - print(prefix + "f_left: " + str(f_left.shape)) - - # last layer does not recurse - bottom_layer = (layer == len(downsample_factors)) - if bottom_layer: - print(prefix + "bottom layer") - print(prefix + "f_out: " + str(f_left.shape)) - return f_left - - # downsample - g_in = downsample( - f_left, - downsample_factors[layer], - 'unet_down_%i_to_%i' % (layer, layer + 1)) - - print(prefix + "g_in: " + str(g_in.shape)) - - # recursive U-net - g_out = unet( - g_in, - num_fmaps=num_fmaps*fmap_inc_factor, - fmap_inc_factor=fmap_inc_factor, - downsample_factors=downsample_factors, - activation=activation, - layer=layer+1) - - print(prefix + "g_out: " + str(g_out.shape)) - - # upsample - g_out_upsampled = upsample( - g_out, - downsample_factors[layer], - num_fmaps, - activation=activation, - name='unet_up_%i_to_%i' % (layer + 1, layer)) - - print(prefix + "g_out_upsampled: " + str(g_out_upsampled.shape)) - - # copy-crop - f_left_cropped = crop_tzyx(f_left, g_out_upsampled.get_shape().as_list()) - - print(prefix + "f_left_cropped: " + str(f_left_cropped.shape)) - - # concatenate along channel dimension - f_right = tf.concat([f_left_cropped, g_out_upsampled], 1) - - print(prefix + "f_right: " + str(f_right.shape)) - - # convolve - f_out = conv_pass( - f_right, - kernel_size=3, - num_fmaps=num_fmaps, - num_repetitions=2, - name='unet_layer_%i_right' % layer) - - print(prefix + "f_out: " + str(f_out.shape)) - - return f_out - - -if __name__ == "__main__": - - # test - raw = tf.placeholder(tf.float32, shape=(1, 1, 84, 268, 268)) - - model = unet(raw, 12, 5, [[1, 3, 3], [1, 3, 3], [1, 3, 3]]) - tf.train.export_meta_graph(filename='unet.meta') - - with tf.Session() as session: - session.run(tf.initialize_all_variables()) - tf.summary.FileWriter('.', graph=tf.get_default_graph()) - # writer = tf.train.SummaryWriter( - # logs_path, graph=tf.get_default_graph()) - - print(model.shape) From f8c0943314dda6b517f3db122ec941e347c049ef Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 05:35:01 -0400 Subject: [PATCH 187/263] move utility code to sub-folder --- linajea/__init__.py | 9 -- linajea/datasets.py | 40 ------ linajea/utils/__init__.py | 8 ++ linajea/{ => utils}/candidate_database.py | 25 +++- linajea/{ => utils}/check_or_create_db.py | 7 +- .../{ => utils}/construct_zarr_filename.py | 2 +- .../{ => utils}/get_next_inference_data.py | 130 +++++++++++------- linajea/{ => utils}/parse_tracks_file.py | 0 linajea/{ => utils}/print_time.py | 0 linajea/utils/write_search_files.py | 129 +++++++++++++++++ 10 files changed, 248 insertions(+), 102 deletions(-) delete mode 100644 linajea/datasets.py create mode 100644 linajea/utils/__init__.py rename linajea/{ => utils}/candidate_database.py (93%) rename linajea/{ => utils}/check_or_create_db.py (88%) rename linajea/{ => utils}/construct_zarr_filename.py (79%) rename linajea/{ => utils}/get_next_inference_data.py (52%) rename linajea/{ => utils}/parse_tracks_file.py (100%) rename linajea/{ => utils}/print_time.py (100%) create mode 100644 linajea/utils/write_search_files.py diff --git a/linajea/__init__.py b/linajea/__init__.py index 4ba426a..9c0fa90 100644 --- a/linajea/__init__.py +++ b/linajea/__init__.py @@ -1,10 +1 @@ # flake8: noqa -from __future__ import absolute_import -from .candidate_database import CandidateDatabase -from .print_time import print_time -from .load_config import load_config, tracking_params_from_config -from .parse_tracks_file import parse_tracks_file -from .construct_zarr_filename import construct_zarr_filename -from .check_or_create_db import checkOrCreateDB -from .datasets import get_source_roi -from .get_next_inference_data import getNextInferenceData diff --git a/linajea/datasets.py b/linajea/datasets.py deleted file mode 100644 index 7cfd48e..0000000 --- a/linajea/datasets.py +++ /dev/null @@ -1,40 +0,0 @@ -import daisy -import json -import os - - -def get_source_roi(data_dir, sample): - - sample_path = os.path.join(data_dir, sample) - - # get absolute paths - if os.path.isfile(sample_path) or sample.endswith((".zarr", ".n5")): - sample_dir = os.path.abspath(os.path.join(data_dir, - os.path.dirname(sample))) - else: - sample_dir = os.path.abspath(os.path.join(data_dir, sample)) - - if os.path.isfile(os.path.join(sample_dir, 'attributes.json')): - - with open(os.path.join(sample_dir, 'attributes.json'), 'r') as f: - attributes = json.load(f) - voxel_size = daisy.Coordinate(attributes['resolution']) - shape = daisy.Coordinate(attributes['shape']) - offset = daisy.Coordinate(attributes['offset']) - source_roi = daisy.Roi(offset, shape*voxel_size) - - return voxel_size, source_roi - - elif os.path.isdir(os.path.join(sample_dir, 'timelapse.zarr')): - - a = daisy.open_ds( - os.path.join(sample_dir, 'timelapse.zarr'), - 'volumes/raw') - - return a.voxel_size, a.roi - - else: - - raise RuntimeError( - "Can't find attributes.json or timelapse.zarr in %s" % - sample_dir) diff --git a/linajea/utils/__init__.py b/linajea/utils/__init__.py new file mode 100644 index 0000000..f79b26d --- /dev/null +++ b/linajea/utils/__init__.py @@ -0,0 +1,8 @@ +# flake8: noqa +from __future__ import absolute_import +from .candidate_database import CandidateDatabase +from .print_time import print_time +from .parse_tracks_file import parse_tracks_file +from .construct_zarr_filename import construct_zarr_filename +from .check_or_create_db import checkOrCreateDB +from .get_next_inference_data import getNextInferenceData diff --git a/linajea/candidate_database.py b/linajea/utils/candidate_database.py similarity index 93% rename from linajea/candidate_database.py rename to linajea/utils/candidate_database.py index e69fb87..be0927e 100644 --- a/linajea/candidate_database.py +++ b/linajea/utils/candidate_database.py @@ -147,6 +147,7 @@ def get_parameters_id( params_collection = self.database['parameters'] query = parameters.query() del query['roi'] + logger.info("Querying ID for parameters %s", query) cnt = params_collection.count_documents(query) if fail_if_not_exists: assert cnt > 0, "Did not find id for parameters %s in %s"\ @@ -189,6 +190,7 @@ def get_parameters_id_round( params_collection = self.database['parameters'] query = parameters.query() del query['roi'] + logger.info("Querying ID for parameters %s", query) entries = [] params = list(query.keys()) for entry in params_collection.find({}): @@ -371,9 +373,28 @@ def get_scores(self, filters=None, eval_params=None): score_collection = self.database['scores'] if eval_params is not None: query.update(eval_params.valid()) - logger.debug("Query: %s", query) + logger.info("Query: %s", query) scores = list(score_collection.find(query)) - logger.debug("Found %d scores" % len(scores)) + logger.info("Found %d scores" % len(scores)) + if len(scores) == 0: + if "fn_div_count_unconnected_parent" in query and \ + query["fn_div_count_unconnected_parent"] == True: + del query["fn_div_count_unconnected_parent"] + if "validation_score" in query and \ + query["validation_score"] == False: + del query["validation_score"] + if "window_size" in query and \ + query["window_size"] == 50: + del query["window_size"] + if "filter_short_tracklets_len" in query and \ + query["filter_short_tracklets_len"] == -1: + del query["filter_short_tracklets_len"] + if "ignore_one_off_div_errors" in query and \ + query["ignore_one_off_div_errors"] == False: + del query["ignore_one_off_div_errors"] + logger.info("Query: %s", query) + scores = list(score_collection.find(query)) + logger.info("Found %d scores" % len(scores)) finally: self._MongoDbGraphProvider__disconnect() diff --git a/linajea/check_or_create_db.py b/linajea/utils/check_or_create_db.py similarity index 88% rename from linajea/check_or_create_db.py rename to linajea/utils/check_or_create_db.py index cf4301b..534eb56 100644 --- a/linajea/check_or_create_db.py +++ b/linajea/utils/check_or_create_db.py @@ -8,7 +8,7 @@ def checkOrCreateDB(db_host, setup_dir, sample, checkpoint, - cell_score_threshold, prefix="linajea_", + cell_score_threshold, roi=None, prefix="linajea_", tag=None, create_if_not_found=True): db_host = db_host @@ -17,6 +17,7 @@ def checkOrCreateDB(db_host, setup_dir, sample, checkpoint, info["iteration"] = checkpoint info["cell_score_threshold"] = cell_score_threshold info["sample"] = os.path.basename(sample) + info["roi"] = roi if tag is not None: info["tag"] = tag @@ -26,6 +27,8 @@ def checkOrCreateDB(db_host, setup_dir, sample, checkpoint, def checkOrCreateDBMeta(db_host, db_meta_info, prefix="linajea_", create_if_not_found=True): + db_meta_info_no_roi = {k: v for k, v in db_meta_info.items() if k != "roi"} + client = pymongo.MongoClient(host=db_host) for db_name in client.list_database_names(): if not db_name.startswith(prefix): @@ -47,7 +50,7 @@ def checkOrCreateDBMeta(db_host, db_meta_info, prefix="linajea_", assert query_result == 1 query_result = db["db_meta_info"].find_one() del query_result["_id"] - if query_result == db_meta_info: + if query_result == db_meta_info or query_result == db_meta_info_no_roi: logger.info("{}: {} (accessed)".format(db_name, query_result)) break else: diff --git a/linajea/construct_zarr_filename.py b/linajea/utils/construct_zarr_filename.py similarity index 79% rename from linajea/construct_zarr_filename.py rename to linajea/utils/construct_zarr_filename.py index e4cdfb1..6962ded 100644 --- a/linajea/construct_zarr_filename.py +++ b/linajea/utils/construct_zarr_filename.py @@ -8,4 +8,4 @@ def construct_zarr_filename(config, sample, checkpoint): os.path.basename(sample) + 'predictions' + (config.general.tag if config.general.tag is not None else "") + str(checkpoint) + "_" + - str(config.inference.cell_score_threshold).replace(".", "_") + '.zarr') + str(config.inference_data.cell_score_threshold).replace(".", "_") + '.zarr') diff --git a/linajea/get_next_inference_data.py b/linajea/utils/get_next_inference_data.py similarity index 52% rename from linajea/get_next_inference_data.py rename to linajea/utils/get_next_inference_data.py index 3dcee39..f5e8435 100644 --- a/linajea/get_next_inference_data.py +++ b/linajea/utils/get_next_inference_data.py @@ -6,8 +6,8 @@ import attr import toml -from linajea import (CandidateDatabase, - checkOrCreateDB) +from linajea.utils import (CandidateDatabase, + checkOrCreateDB) from linajea.config import (InferenceDataTrackingConfig, SolveParametersMinimalConfig, SolveParametersNonMinimalConfig, @@ -20,30 +20,32 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): config = maybe_fix_config_paths_to_machine_and_load(args.config) config = TrackingConfig(**config) + # print(config) - if args.validation: - inference = deepcopy(config.validate_data) + if hasattr(args, "validation") and args.validation: + inference_data = deepcopy(config.validate_data) checkpoints = config.validate_data.checkpoints else: - inference = deepcopy(config.test_data) + inference_data = deepcopy(config.test_data) checkpoints = [config.test_data.checkpoint] - if args.validate_on_train: - inference.data_sources = deepcopy(config.train_data.data_sources) + if hasattr(args, "validate_on_train") and args.validate_on_train: + inference_data.data_sources = deepcopy(config.train_data.data_sources) - if args.checkpoint > 0: + if hasattr(args, "checkpoint") and args.checkpoint > 0: checkpoints = [args.checkpoint] - if (is_solve or is_evaluate) and args.val_param_id is not None: - config = fix_val_param_pid(args, config, checkpoints, inference) - if (is_solve or is_evaluate) and \ - (args.param_id is not None or - (hasattr(args, "param_ids") and args.param_ids is not None)): - config = fix_param_pid(args, config, checkpoints, inference) - - max_cell_move = max(config.extract.edge_move_threshold.values()) + try: + max_cell_move = max(config.extract.edge_move_threshold.values()) + except: + max_cell_move = None for pid in range(len(config.solve.parameters)): if config.solve.parameters[pid].max_cell_move is None: + assert max_cell_move is not None, ( + "Please provide a max_cell_move value, either as " + "extract.edge_move_threshold or directly in the parameter sets " + "in solve.parameters! (What is the maximum distance that a cell " + "can move between two adjacent frames?)") config.solve.parameters[pid].max_cell_move = max_cell_move os.makedirs("tmp_configs", exist_ok=True) @@ -58,21 +60,30 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): config.solve.parameters = [solve_parameters] solve_parameters_sets = deepcopy(config.solve.parameters) for checkpoint in checkpoints: - inference_data = { + if hasattr(args, "val_param_id") and (is_solve or is_evaluate) and \ + args.val_param_id is not None: + config = fix_val_param_pid(args, config, checkpoint) + if hasattr(args, "param_id") and (is_solve or is_evaluate) and \ + (args.param_id is not None or + (hasattr(args, "param_ids") and args.param_ids is not None)): + config = fix_param_pid(args, config, checkpoint, inference_data) + inference_data_tmp = { 'checkpoint': checkpoint, - 'cell_score_threshold': inference.cell_score_threshold} - for sample in inference.data_sources: + 'cell_score_threshold': inference_data.cell_score_threshold} + for sample in inference_data.data_sources: sample = deepcopy(sample) - if sample.db_name is None and not config.predict.no_db_access: + if sample.db_name is None and hasattr(config, "predict") and \ + not config.predict.no_db_access: sample.db_name = checkOrCreateDB( config.general.db_host, config.general.setup_dir, sample.datafile.filename, checkpoint, - inference.cell_score_threshold, + inference_data.cell_score_threshold, + roi=sample.roi, tag=config.general.tag) - inference_data['data_source'] = sample - config.inference = InferenceDataTrackingConfig(**inference_data) # type: ignore + inference_data_tmp['data_source'] = sample + config.inference = InferenceDataTrackingConfig(**inference_data_tmp) # type: ignore if is_solve: config = fix_solve_roi(config) if config.solve.write_struct_svm: @@ -80,6 +91,7 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): checkpoint, os.path.basename(sample.datafile.filename)) if is_evaluate: + print(solve_parameters_sets, len(solve_parameters_sets)) for solve_parameters in solve_parameters_sets: solve_parameters = deepcopy(solve_parameters) config.solve.parameters = [solve_parameters] @@ -94,53 +106,74 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): yield config -def fix_val_param_pid(args, config, checkpoints, inference): - if args.validation: - sample_name = config.test_data.data_sources[0].datafile.filename - threshold = config.test_data.cell_score_threshold +def fix_val_param_pid(args, config, checkpoint): + if hasattr(args, "validation") and args.validation: + tmp_data = config.test_data + else: + tmp_data = config.validate_data + assert len(tmp_data.data_sources) == 1, ( + "val_param_id only supported with a single sample") + if tmp_data.data_sources[0].db_name is None: + db_meta_info = { + "sample": tmp_data.data_sources[0].datafile.filename, + "iteration": checkpoint, + "cell_score_threshold": tmp_data.cell_score_threshold, + "roi": tmp_data.data_sources[0].roi + } + db_name = None else: - sample_name = config.validate_data.data_sources[0].datafile.filename - threshold = config.validate_data.cell_score_threshold + db_name = tmp_data.data_sources[0].db_name + db_meta_info = None + pid = args.val_param_id config = fix_solve_parameters_with_pids( - config, sample_name, checkpoints[0], inference, [pid], - threshold=threshold) + config, [pid], db_meta_info, db_name) config.solve.parameters[0].val = False return config -def fix_param_pid(args, config, checkpoints, inference): - assert len(checkpoints) == 1, "use param_id to reevaluate a single instance" - sample_name = inference.data_sources[0].datafile.filename +def fix_param_pid(args, config, checkpoint, inference_data): + assert len(inference_data.data_sources) == 1, ( + "param_id(s) only supported with a single sample") + if inference_data.data_sources[0].db_name is None: + db_meta_info = { + "sample": inference_data.data_sources[0].datafile.filename, + "iteration": checkpoint, + "cell_score_threshold": inference_data.cell_score_threshold + } + db_name = None + else: + db_name = inference_data.data_sources[0].db_name + db_meta_info = None + if hasattr(args, "param_ids") and args.param_ids is not None: - pids = list(range(int(args.param_ids[0]), int(args.param_ids[1])+1)) + if len(args.param_ids) > 2: + pids = args.param_ids + else: + pids = list(range(int(args.param_ids[0]), int(args.param_ids[1])+1)) else: pids = [args.param_id] - config = fix_solve_parameters_with_pids(config, sample_name, - checkpoints[0], inference, pids) + config = fix_solve_parameters_with_pids(config, pids, db_meta_info, db_name) return config def fix_solve_roi(config): for i in range(len(config.solve.parameters)): - config.solve.parameters[i].roi = config.inference.data_source.roi + config.solve.parameters[i].roi = config.inference_data.data_source.roi return config -def fix_solve_parameters_with_pids(config, sample_name, checkpoint, inference, - pids, threshold=None): - if inference.data_sources[0].db_name is not None: - db_name = inference.data_sources[0].db_name - else: +def fix_solve_parameters_with_pids(config, pids, db_meta_info=None, db_name=None): + if db_name is None: db_name = checkOrCreateDB( config.general.db_host, config.general.setup_dir, - sample_name, - checkpoint, - threshold if threshold is not None - else inference.cell_score_threshold, + db_meta_info["sample"], + db_meta_info["iteration"], + db_meta_info["cell_score_threshold"], + roi=db_meta_info["roi"], tag=config.general.tag, create_if_not_found=False) assert db_name is not None, "db for pid {} not found".format(pids) @@ -162,7 +195,8 @@ def fix_solve_parameters_with_pids(config, sample_name, checkpoint, inference, if parameters is None: continue logger.info("getting params %s (id: %s) from database %s (sample: %s)", - parameters, pid, db_name, sample_name) + parameters, pid, db_name, + db_meta_info["sample"] if db_meta_info is not None else None) try: solve_parameters = SolveParametersMinimalConfig(**parameters) # type: ignore config.solve.non_minimal = False diff --git a/linajea/parse_tracks_file.py b/linajea/utils/parse_tracks_file.py similarity index 100% rename from linajea/parse_tracks_file.py rename to linajea/utils/parse_tracks_file.py diff --git a/linajea/print_time.py b/linajea/utils/print_time.py similarity index 100% rename from linajea/print_time.py rename to linajea/utils/print_time.py diff --git a/linajea/utils/write_search_files.py b/linajea/utils/write_search_files.py new file mode 100644 index 0000000..95c2d99 --- /dev/null +++ b/linajea/utils/write_search_files.py @@ -0,0 +1,129 @@ +import argparse +import itertools +import logging +import random + +import toml + +from linajea.config import load_config + +logger = logging.getLogger(__name__) + + +def write_search_configs(config, random_search, output_file, num_configs=None): + """Create list of ILP weights sets based on configuration + + Args + ---- + parameters_search: dict + Parameter search object that is used to create list of + individual sets of parameters + random_search: bool + Do grid search or random search + output_file: str + Write configurations to this file + num_configs: int + How many configurations to create; if None random has to be false, + then all possible grid configurations will be created, otherwise + a random subset of them. + + Returns + ------- + List of ILP weights configurations + + + Notes + ----- + Random search: + How random values are determined depends on respective type of + values, number of combinations determined by num_configs + Not a list or list with single value: + interpreted as single value that is selected + List of str or more than two values: + interpreted as discrete options + List of two lists: + Sample outer list discretely. If inner list contains + strings, sample discretely again; if inner list contains + numbers, sample uniformly from range + List of two numbers: + Sample uniformly from range + Grid search: + Perform some type cleanup and compute cartesian product with + itertools.product. If num_configs is set, shuffle list and take + the num_configs first ones. + """ + params = {k:v + for k,v in config.items() + if v is not None} + params.pop('num_configs', None) + + if random_search: + search_configs = [] + assert num_configs is not None, \ + "set num_configs kwarg when using random search!" + + for _ in range(num_configs): + conf = {} + for k, v in params.items(): + if not isinstance(v, list): + value = v + elif len(v) == 1: + value = v[0] + elif isinstance(v[0], str) or len(v) > 2: + value = random.choice(v) + elif len(v) == 2 and isinstance(v[0], list) and isinstance(v[1], list) and \ + isinstance(v[0][0], str) and isinstance(v[1][0], str): + subset = random.choice(v) + value = random.choice(subset) + else: + assert len(v) == 2, \ + "possible options per parameter for random search: " \ + "single fixed value, upper and lower bound, " \ + "set of string values ({})".format(v) + if isinstance(v[0], list): + idx = random.randrange(len(v[0])) + value = random.uniform(v[0][idx], v[1][idx]) + else: + value = random.uniform(v[0], v[1]) + if value == "": + value = None + conf[k] = value + search_configs.append(conf) + else: + if params.get('cell_cycle_key') == '': + params['cell_cycle_key'] = None + elif isinstance(params.get('cell_cycle_key'), list) and \ + '' in params['cell_cycle_key']: + params['cell_cycle_key'] = [k if k != '' else None + for k in params['cell_cycle_key']] + + search_configs = [ + dict(zip(params.keys(), x)) + for x in itertools.product(*params.values())] + + if num_configs and num_configs <= len(search_configs): + random.shuffle(search_configs) + search_configs = search_configs[:num_configs] + + search_configs = {"solve" : { "parameters": search_configs}} + with open(output_file, 'w') as f: + print(search_configs) + toml.dump(search_configs, f) + + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument('--config', type=str, required=True, + help='sample config file') + parser.add_argument('--output', type=str, required=True, + help='output file') + parser.add_argument('--num_configs', type=int, default=None, + help='how many configurations to create') + parser.add_argument('--random', action="store_true", + help='do random search (as opposed to grid search)') + args = parser.parse_args() + + config = load_config(args.config) + write_search_configs(config, args.random, args.output, + num_configs=args.num_configs) From ee9bf22ad8a25dc98ad06bf21be7ed674aef195e Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 09:44:52 -0400 Subject: [PATCH 188/263] update config: add docstrings, some cleanup --- linajea/config/__init__.py | 48 +++- linajea/config/augment.py | 271 ++++++++++++++++-- linajea/config/cell_cycle_config.py | 29 +- linajea/config/cnn_config.py | 158 ++++++++-- linajea/config/data.py | 148 +++++++++- linajea/config/evaluate.py | 106 ++++++- linajea/config/extract.py | 41 ++- linajea/config/general.py | 33 ++- linajea/config/job.py | 31 +- linajea/config/optimizer.py | 139 ++++++++- linajea/config/predict.py | 66 ++++- linajea/config/solve.py | 216 ++++++++++++-- linajea/config/tracking_config.py | 122 ++++++-- linajea/config/train.py | 109 ++++++- linajea/config/train_test_validate_data.py | 178 ++++++++++-- linajea/config/unet_config.py | 68 ++++- linajea/config/utils.py | 199 ++++++++++--- linajea/evaluation/analyze_results.py | 12 +- linajea/evaluation/evaluate_setup.py | 28 +- linajea/load_config.py | 46 --- .../extract_edges_blockwise.py | 44 ++- .../process_blockwise/predict_blockwise.py | 79 ++--- linajea/process_blockwise/solve_blockwise.py | 7 +- linajea/tracking/greedy_track.py | 2 +- 24 files changed, 1828 insertions(+), 352 deletions(-) delete mode 100644 linajea/load_config.py diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index 4204edf..ebab9ac 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -1,6 +1,46 @@ +"""Linajea configuration + +This module defines the components of the configuration files used +by linajea. + +Submodules +---------- + +augment: + defines configuration used for data augmentation during training +cell_cycle_config: + combines other config submodules into config for cell state classifier +cnn_config: + defines configuration used for CNNs (e.g. for cell state classifier) +data: + defines configuration used for data samples +evaluate: + defines configuration used for evaluation of results +extract: + defines configuration used to extract edges from predicted nodes +general: + defines general configuration used by several steps +job: + defines configuration used to create HPC cluster jobs +predict: + defines configuration used to predict cells and vectors +solve: + defines configuration used by ILP to compute tracks +tracking_config: + combines other config submodules into config for tracking model +train: + defines configuration used for model training +train_test_validate_data: + defines configuration to assemble samples to train/test/val data +unet_config: + defines configuration used for U-Net (e.g. for tracking model) +utils: + defines utility functions to read and handle config +""" # flake8: noqa -from .augment import AugmentConfig +from .augment import (AugmentTrackingConfig, + AugmentCellCycleConfig) from .cell_cycle_config import CellCycleConfig from .cnn_config import (VGGConfig, ResNetConfig, @@ -21,5 +61,7 @@ TrainCellCycleConfig) from .train_test_validate_data import (InferenceDataTrackingConfig, InferenceDataCellCycleConfig) - -from .utils import maybe_fix_config_paths_to_machine_and_load +from .unet_config import UnetConfig +from .utils import (dump_config, + load_config, + maybe_fix_config_paths_to_machine_and_load) diff --git a/linajea/config/augment.py b/linajea/config/augment.py index 0ce3d5f..6dd9c33 100644 --- a/linajea/config/augment.py +++ b/linajea/config/augment.py @@ -1,15 +1,54 @@ -import attr +"""Data augmentation configuration + +This modules defines the data augmentation configuration options. +Options are: + - Elastic + - Intensity + - Shift + - Shuffle Input Channels + - Gaussian Noise + - Speckle Noise + - Salt and Pepper Noise + - Jitter Noise + - Zoom + - Histogram + +Notes +----- +Only augmentation specified in configuration file is used, +default is off +""" from typing import List +import attr + from .utils import ensure_cls @attr.s(kw_only=True) class AugmentElasticConfig: - control_point_spacing = attr.ib(type=List[int]) - jitter_sigma = attr.ib(type=List[int]) - rotation_min = attr.ib(type=int) - rotation_max = attr.ib(type=int) + """Defines options for elastic augment + + Attributes + ---------- + control_point_spacing: list of int + Distance between adjacent control points, one value per dimension + jitter_sigma: list of int + Sigma used to shift control points, one value per dimension + rotation_min, rotation_max: int + Range of angles by which data is rotated, in degree + rotation_3d: bool + Use 3d rotation or successive 2d rotations + subsample: int + Subsample ROI to speed up computation, up to 4 is usually fine + use_fast_points_transform: bool + Use fast transform to augment points, not as well tested + + """ + control_point_spacing = attr.ib(type=List[int], default=[15, 15, 15]) + jitter_sigma = attr.ib(type=List[int], default=[1, 1, 1]) + rotation_min = attr.ib(type=int, default=-45) + rotation_max = attr.ib(type=int, default=45) rotation_3d = attr.ib(type=bool, default=False) subsample = attr.ib(type=int, default=1) use_fast_points_transform = attr.ib(type=bool, default=False) @@ -17,59 +56,196 @@ class AugmentElasticConfig: @attr.s(kw_only=True) class AugmentIntensityConfig: - scale = attr.ib(type=List[float]) - shift = attr.ib(type=List[float]) + """Defines options for intensity augment + + Attributes + ---------- + scale: list of float + Expects two values, lower and upper range of random factor by + which data is multiplied/scaled, usually slightly smaller and + larger than 1 + shift: list of float + Expects two values, lower and upper range of random term + which is added to data/by which data is shifted, usually + slightly smaller and larger than 0 + """ + scale = attr.ib(type=List[float], default=[0.9, 1.1]) + shift = attr.ib(type=List[float], default=[-0.001, 0.001]) @attr.s(kw_only=True) class AugmentShiftConfig: - prob_slip = attr.ib(type=float) - prob_shift = attr.ib(type=float) - sigma = attr.ib(type=List[int]) + """Defines options for shift augment + + Attributes + ---------- + prob_slip: float + Probability that center frame is shifted (independently) + prob_shift: float + Probability that center frame and all following ones are shifted + sigma: list of int + Standard deviation of shift in each dimension, if single value + extended to all dimensions. + """ + prob_slip = attr.ib(type=float, default=0.2) + prob_shift = attr.ib(type=float, default=0.2) + sigma = attr.ib(type=List[int], default=[0, 4, 4, 4]) @attr.s(kw_only=True) class AugmentSimpleConfig: - mirror = attr.ib(type=List[int]) - transpose = attr.ib(type=List[int]) + """Defines options for simple flip and transpose augment + + Attributes + ---------- + mirror, transpose: list of int + List of dimensions to be mirrored/transposed, + e.g. for 3d+t data and ignoring time: [1, 2, 3] + + Notes + ----- + It might make sense to not transpose z for anisotropic data + """ + mirror = attr.ib(type=List[int], default=[1, 2, 3]) + transpose = attr.ib(type=List[int], default=[1, 2, 3]) @attr.s(kw_only=True) class AugmentNoiseGaussianConfig: - var = attr.ib(type=float) + """Defines options for Gaussian noise augment, used scikit-image + + Attributes + ---------- + var: float + Variance of Gaussian Noise + """ + var = attr.ib(type=float, default=0.01) @attr.s(kw_only=True) class AugmentNoiseSpeckleConfig: - var = attr.ib(type=float) + """Defines options for Speckle noise augment, uses scikit-image + + Attributes + ---------- + var: float + Variance of Speckle Noise + """ + var = attr.ib(type=float, default=0.05) @attr.s(kw_only=True) class AugmentNoiseSaltPepperConfig: - amount = attr.ib(type=float) + """Defines options for S&P noise augment, uses scikit-image + + Attributes + ---------- + amount: float + Amount of S&P noise to be added + """ + amount = attr.ib(type=float, default=0.0001) @attr.s(kw_only=True) class AugmentJitterConfig: - jitter = attr.ib(type=List[int]) + """Defines options for Jitter noise augment + + Notes + ----- + Only used in cell state classifier, not in tracking + + Attributes + ---------- + jitter: list of int + How far to shift cell location, one value per dimension, + sampled uniformly from [-j, +j] in each dimension + """ + jitter = attr.ib(type=List[int], default=[0, 3, 3, 3]) @attr.s(kw_only=True) class AugmentZoomConfig: - factor_min = attr.ib(type=float) - factor_max = attr.ib(type=float) - spatial_dims = attr.ib(type=int) + """Defines options for Zoom augment + + Attributes + ---------- + factor_min, factor_max: float + Range of random factor by which to zoom in/out, usually + slightly smaller and larger than 1 + spatial_dims: int + Number of spatial dimensions, typically 2 or 3, + assumed to be the last ones (i.e., data[-spatial_dims]) + """ + factor_min = attr.ib(type=float, default=0.85) + factor_max = attr.ib(type=float, default=1.25) + spatial_dims = attr.ib(type=int, default=3) @attr.s(kw_only=True) class AugmentHistogramConfig: - range_low = attr.ib(type=float) - range_high = attr.ib(type=float) + """Defines options for Zoom augment + + Attributes + ---------- + range_low, range_high: float + Range of random factor by which to zoom in/out, usually + smaller than 1 and around 1 + after_int_aug: bool + Perform Histogram augmentation after Intensity augment + + Notes + ----- + Assumes data in range [0, 1] + + shift = (1.0 - data) + data = data * factor + (data * shift) * (1.0 - factor) + """ + range_low = attr.ib(type=float, default=0.1) + range_high = attr.ib(type=float, default=1.0) after_int_aug = attr.ib(type=bool, default=True) @attr.s(kw_only=True) -class AugmentConfig: +class NormalizeConfig: + """Defines options for Data Normalization + + Attributes + ---------- + type: str + Which kind of data normalization/standardization to use: + [None or default, minmax, percminmax, mean, median] + norm_bounds: list of int + Used for minmax norm, expects [min, max] used to norm data + E.g. if int16 is used but data is only in range [2000, 7500] + perc_min, perc_max: str + Which percentile to use for data normalization, have to be + precomputed and stored in data_config.toml per sample + E.g. + [stats] + perc0_01 = 2036 + perc3 = 2087 + perc99_8 = 4664 + perc99_99 = 7206 + """ + type = attr.ib(type=str, default=None) + norm_bounds = attr.ib(type=List[int], default=None) + perc_min = attr.ib(type=str, default=None) + perc_max = attr.ib(type=str, default=None) + + +@attr.s(kw_only=True) +class _AugmentConfig: + """Combines different augments into one section in configuration + + By default all augmentations are turned off if not set otherwise. + Use one of the derived classes below depending on the use case + (tracking vs cell state/cycle classifier) + + Notes + ----- + If implementing new augments, add them to this list. + """ elastic = attr.ib(converter=ensure_cls(AugmentElasticConfig), default=None) shift = attr.ib(converter=ensure_cls(AugmentShiftConfig), default=None) + shuffle_channels = attr.ib(type=bool, default=False) intensity = attr.ib(converter=ensure_cls(AugmentIntensityConfig), default=None) simple = attr.ib(converter=ensure_cls(AugmentSimpleConfig), @@ -87,18 +263,53 @@ class AugmentConfig: @attr.s(kw_only=True) -class AugmentTrackingConfig(AugmentConfig): - reject_empty_prob = attr.ib(type=float) # (default=1.0?) - norm_bounds = attr.ib(type=List[int], default=None) - divisions = attr.ib(type=bool) # float for percentage? - normalization = attr.ib(type=str, default=None) - perc_min = attr.ib(type=str, default=None) - perc_max = attr.ib(type=str, default=None) +class AugmentTrackingConfig(_AugmentConfig): + """Specialized class for augmentation for tracking + + Attributes + ---------- + reject_empty_prob: float + Probability that completely empty patches are discarded + divisions: float + Choose (x*100)% of patches such that they include a division (e.g. 0.25) + point_balance_radius: int + Defines radius per point, the more other points within radius + the lower the probability that point is picked, helps to avoid + oversampling of dense regions + """ + reject_empty_prob = attr.ib(type=float, default=1.0) # (default=1.0?) + divisions = attr.ib(type=float, default=0.0) point_balance_radius = attr.ib(type=int, default=1) @attr.s(kw_only=True) -class AugmentCellCycleConfig(AugmentConfig): +class AugmentCellCycleConfig(_AugmentConfig): + """Specialized class for augmentation for cell state/cycle classifier + + Attributes + ---------- + min_key, max_key: str + Which statistic to use for normalization, have to be + precomputed and stored in data_config.toml per sample + E.g. + [stats] + min = 1874 + max = 65535 + mean = 2260 + std = 282 + perc0_01 = 2036 + perc3 = 2087 + perc99_8 = 4664 + perc99_99 = 7206 + norm_min, norm_max: int + Default values used it min/max_key do not exist + jitter: + See AugmentJitterConfig, shift selected point slightly + + Notes + ----- + TODO: move data normalization info outside + """ min_key = attr.ib(type=str, default=None) max_key = attr.ib(type=str, default=None) norm_min = attr.ib(type=int, default=None) diff --git a/linajea/config/cell_cycle_config.py b/linajea/config/cell_cycle_config.py index 0d3bd0a..03c2536 100644 --- a/linajea/config/cell_cycle_config.py +++ b/linajea/config/cell_cycle_config.py @@ -1,6 +1,10 @@ +"""Configuration for Cell State/Cycle Classifier + +Combines configuration sections into one big configuration +that can be used to define and create cell state classifier models +""" import attr -from linajea import load_config from .cnn_config import (EfficientNetConfig, ResNetConfig, VGGConfig) @@ -15,10 +19,11 @@ TrainDataCellCycleConfig, ValidateDataCellCycleConfig) from .train import TrainCellCycleConfig -from .utils import ensure_cls +from .utils import (load_config, + ensure_cls) -def model_converter(): +def _model_converter(): def converter(val): if val['network_type'].lower() == "vgg": return VGGConfig(**val) # type: ignore @@ -36,7 +41,7 @@ def converter(val): class CellCycleConfig: path = attr.ib(type=str) general = attr.ib(converter=ensure_cls(GeneralConfig)) - model = attr.ib(converter=model_converter()) + model = attr.ib(converter=_model_converter()) optimizerTF1 = attr.ib(converter=ensure_cls(OptimizerTF1Config), default=None) optimizerTF2 = attr.ib(converter=ensure_cls(OptimizerTF2Config), default=None) optimizerTorch = attr.ib(converter=ensure_cls(OptimizerTorchConfig), default=None) @@ -50,20 +55,16 @@ class CellCycleConfig: @classmethod def from_file(cls, path): config_dict = load_config(path) - # if 'path' in config_dict: - # assert path == config_dict['path'], "{} {}".format(path, config_dict['path']) - # del config_dict['path'] - try: - del config_dict['path'] - except: - pass - return cls(path=path, **config_dict) # type: ignore + config_dict["path"] = path + return cls(**config_dict) # type: ignore def __attrs_post_init__(self): assert (int(bool(self.optimizerTF1)) + int(bool(self.optimizerTF2)) + int(bool(self.optimizerTorch))) == 1, \ - "please specify exactly one optimizer config (tf1, tf2, torch)" + "please specify only one optimizer config (tf1, tf2, torch)" - if self.predict.use_swa is None: + if self.predict is not None and \ + self.train is not None and \ + self.predict.use_swa is None: self.predict.use_swa = self.train.use_swa diff --git a/linajea/config/cnn_config.py b/linajea/config/cnn_config.py index c7dfbcb..d6a7ca5 100644 --- a/linajea/config/cnn_config.py +++ b/linajea/config/cnn_config.py @@ -1,6 +1,11 @@ -import attr +"""Configuration used to define CNN architecture + +Currently supports VGG/ResNet and EfficientNet style networks. +""" from typing import List +import attr + from .job import JobConfig from .utils import (ensure_cls, _check_nd_shape, @@ -9,15 +14,72 @@ @attr.s(kw_only=True) -class CNNConfig: +class _CNNConfig: + """Defines base network class with common parameters + + Attributes + ---------- + path_to_script: str + Location of training script + input_shape: list of int + 4d (t+3d) shape (in voxels) of input to network + pad_raw: list of int + Padding added to sample to allow for sampling of cells at the + boundary, 4d (t+3d) + activation: str + Activation function used by the network, given string has to be + supported by the used framework + padding: str + SAME or VALID, type of padding used in convolutions + merge_time_voxel_size: int + When to use 4d convolutions to merge time dimension into + spatial dimensions, based on voxel_size + Voxel_size doubles after each pooling layer, + if voxel_size >= merge_time_voxel_size, insert 4d conv + use_dropout: bool + If dropout (with rate 0.1) is used in conv layers + use_bias: bool + Whether a bias variable is used in conv layer + use_global_pool: bool + Whether global pooling instead of fully-connected layers is + used at the end of the network + use_conv4d: bool + Whether 4d convolutions are used for 4d data or + fourth dimension is interpreted as channels + (similar to RGB data) + num_classes: int + Number of output classes + classes: list of str + Name of each class + class_ids: list of int + ID for each class + class_sampling_weights: list of int + Ratio with which each class is sampled per batch, + does not have to be normalized to sum to 1 + network_type: str + Type of netowrk used, one of ["vgg", "resnet", "efficientnet"] + make_isotropic: bool + Do not pool anisotropic dimension until data is approx isotropic + regularizer_weight: float + Weight for l2 weight regularization + with_polar: bool + For C. elegans, should polar body class be included? + focal_loss: bool + Whether to use focal loss instead of plain cross entropy + classify_dataset: bool + For testing purposes, ignore label and try to decide which + data sample a cell is orginated from + """ path_to_script = attr.ib(type=str) # shape -> voxels, size -> world units input_shape = attr.ib(type=List[int], validator=[_int_list_validator, - _check_nd_shape(4)]) + _check_nd_shape(4)], + default=[5, 32, 32, 32]) pad_raw = attr.ib(type=List[int], validator=[_int_list_validator, - _check_nd_shape(4)]) + _check_nd_shape(4)], + default=[3, 30, 30, 30]) activation = attr.ib(type=str, default='relu') padding = attr.ib(type=str, default='SAME') merge_time_voxel_size = attr.ib(type=int, default=1) @@ -27,8 +89,8 @@ class CNNConfig: use_global_pool = attr.ib(type=bool, default=True) use_conv4d = attr.ib(type=bool, default=True) num_classes = attr.ib(type=int, default=3) - classes = attr.ib(type=List[str]) - class_ids = attr.ib(type=List[int]) + classes = attr.ib(type=List[str], default=["daughter", "mother", "normal"]) + class_ids = attr.ib(type=List[int], default=[2, 1, 0]) class_sampling_weights = attr.ib(type=List[int], default=[6, 2, 2, 2]) network_type = attr.ib( type=str, @@ -40,30 +102,86 @@ class CNNConfig: classify_dataset = attr.ib(type=bool, default=False) @attr.s(kw_only=True) -class VGGConfig(CNNConfig): - num_fmaps = attr.ib(type=int) +class VGGConfig(_CNNConfig): + """Specialized class for VGG style networks + + Attributes + ---------- + num_fmaps: int + Number of channels to create in first convolution + fmap_inc_factors: list of int + By which factor to increase number of channels during each + pooling step, number of values depends on number of pooling + steps + downsample_factors: list of list of int + By which factor to downsample during pooling, one value per + dimension per pooling step + kernel_sizes: list of list of int + Size of convolutional kernels, length of outer list depends + on number of pooling steps, length of inner list depends on + number of convolutions per step + net_name: str + Name of network + """ + num_fmaps = attr.ib(type=int, default=12) fmap_inc_factors = attr.ib(type=List[int], - validator=_int_list_validator) + validator=_int_list_validator, + default=[2, 2, 2, 1]) downsample_factors = attr.ib(type=List[List[int]], - validator=_list_int_list_validator) + validator=_list_int_list_validator, + default=[[2, 2, 2], + [2, 2, 2], + [2, 2, 2], + [1, 1, 1]]) kernel_sizes = attr.ib(type=List[List[int]], - validator=_list_int_list_validator) - fc_size = attr.ib(type=int) + validator=_list_int_list_validator, + default=[[3, 3], + [3, 3], + [3, 3, 3, 3], + [3, 3, 3, 3]]) + fc_size = attr.ib(type=int, default=512) net_name = attr.ib(type=str, default="vgg") @attr.s(kw_only=True) -class ResNetConfig(CNNConfig): +class ResNetConfig(_CNNConfig): + """Specialized class for ResNet style networks + + Attributes + ---------- + net_name: + Name of network + resnet_size: str + Size of network, one of ["18", "34", "50", "101", None] + num_blocks: list of int + If resnet_size is None, use this many residual blocks per step + use_bottleneck: + If resnet_size is None, if bottleneck style blocks are used + num_fmaps: str + Number of feature maps used per step (typically 4 steps) + """ net_name = attr.ib(type=str, default="resnet") - resnet_size = attr.ib(type=str, default="18") - num_blocks = attr.ib(default=None, type=List[int], - validator=_int_list_validator) - use_bottleneck = attr.ib(default=None, type=bool) + resnet_size = attr.ib(type=str, default=None) + num_blocks = attr.ib(type=List[int], + validator=_int_list_validator, + default=[2, 2, 2, 2]) + use_bottleneck = attr.ib(type=bool, default=False) num_fmaps = attr.ib(type=List[int], - validator=_int_list_validator) + validator=_int_list_validator, + default=[16, 32, 64, 96]) @attr.s(kw_only=True) -class EfficientNetConfig(CNNConfig): +class EfficientNetConfig(_CNNConfig): + """Specialized class for EfficientNet style networks + + Attributes + ---------- + net_name: + Name of network + efficientnet_size: + Which efficient net size to use, + one of "B0" to "B10" + """ net_name = attr.ib(type=str, default="efficientnet") - efficientnet_size = attr.ib(type=str, default="B01") + efficientnet_size = attr.ib(type=str, default="B0") diff --git a/linajea/config/data.py b/linajea/config/data.py index 768515a..cf0ca02 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -1,21 +1,56 @@ +"""Configuration used to define a Data Sample (DataSource) + +One DataSource has to be defined for each sample. Typically one source +is defined by one file. If no training and prediction should be +performed it can alternatively be defined by a database (db_name). +Otherwise the database will be uniquely identified based on file and +prediction parameters. +""" import os from typing import List import attr +import daisy import zarr -from linajea import load_config -from .utils import ensure_cls +from .utils import (ensure_cls, + load_config, + _check_nested_nd_shape, + _list_int_list_validator) @attr.s(kw_only=True) class DataROIConfig: + """Defines a ROI (region of interest) + + Attributes + ---------- + offset: list of int + Offset relative to origin + shape: list of int + Shape (not end!) of region, in world coordinates + """ offset = attr.ib(type=List[int], default=None) shape = attr.ib(type=List[int], default=None) @attr.s(kw_only=True) class DataFileConfig: + """Defines a data file + + Attributes + ---------- + filename: str + Path to data file/directory + group: str + Which array/group in file to use (for n5/zarr/hdf) + file_roi: DataROIConfig + Size of data/ROI contained in file, determined automatically + file_voxel_size: list of int + Voxel size of data in file, determined automatically + file_track_range: list of int + deprecated + """ filename = attr.ib(type=str) group = attr.ib(type=str, default=None) file_roi = attr.ib(default=None) @@ -23,17 +58,35 @@ class DataFileConfig: file_track_range = attr.ib(type=List[int], default=None) def __attrs_post_init__(self): + """Read voxel size and ROI from file + + If n5/zarr, info should be contained in meta data. + Otherwise location should contain a data_config.toml file with + the respective information. + + Notes + ----- + Example for data_config.toml file: + [general] + zarr_file = "emb.zarr" + mask_file = "emb_mask.hdf" + shape = [425, 41, 512, 512] + resolution = [1, 5, 1, 1] + offset = [0, 0, 0, 0] + tracks_file = "mskcc_emb_tracks.txt" + daughter_cells_file = "mskcc_emb_tracks_daughters.txt" + + [stats] + dtype = "uint16" + min = 1874 + max = 655535 + """ if os.path.splitext(self.filename)[1] in (".n5", ".zarr"): - if "nested" in self.group: - store = zarr.NestedDirectoryStore(self.filename) - else: - store = self.filename - container = zarr.open(store) - attributes = container[self.group].attrs + dataset = daisy.open_ds(self.filename, self.group) - self.file_voxel_size = attributes.voxel_size - self.file_roi = DataROIConfig(offset=attributes.offset, - shape=attributes.shape) # type: ignore + self.file_voxel_size = dataset.voxel_size + self.file_roi = DataROIConfig(offset=dataset.roi.get_offset(), + shape=dataset.roi.get_shape()) # type: ignore else: filename = self.filename is_polar = "polar" in filename @@ -58,6 +111,23 @@ def __attrs_post_init__(self): @attr.s(kw_only=True) class DataDBMetaConfig: + """Defines a configuration uniquely identifying a database + + Attributes + ---------- + setup_dir: str + Name of the used setup + checkpoint: int + Which model checkpoint was used for prediction + cell_score_threshold: float + Which cell score threshold was used during prediction + voxel_size: list of int + What is the voxel size of the data? + + Notes + ----- + TODO add roi? remove voxel_size? + """ setup_dir = attr.ib(type=str, default=None) checkpoint = attr.ib(type=int) cell_score_threshold = attr.ib(type=float) @@ -66,9 +136,63 @@ class DataDBMetaConfig: @attr.s(kw_only=True) class DataSourceConfig: - datafile = attr.ib(converter=ensure_cls(DataFileConfig)) + """Defines a complete data source + + Notes + ----_ + - either datafile or db_name have to be specified + - tracksfile etc have to be specified for training + - gt_db_name has to be specified for evaluation + - voxel_size/roi have to be specified on some level, code tries to + figure it out based on multiple sources (specified in config, file, etc) + Attributes + ---------- + datafile: DataFileConfig + Describes file data source + tracksfile: str + File containing the object tracks used during training + divisionsfile, daughtersfile: str, optional + During training, divisions can optionally be sampled more often. + If enabled in training configuration, tracks in `daughtersfile` + are sampled for this purpose. If `divisionsfile` is set, it should + contain the cells in the temporal context around each entry in + `daughtersfile`, otherwise `tracksfile` is used. + db_name: str + Database to be used as a data source for tracking or as a + destination for prediction. If not set during prediction, name + will be set automatically. + gt_db_name: str + Database containing the ground truth annotations for evaluation. + gt_db_name_polar: str + Database containing the polar body ground truth annotations for + evaluation. + voxel_size: list of int + Voxel size of this data source. If multiple samples are used, + they must have the same voxel size. If not set, tries to determine + information automatically from file or database. + roi: DataROIConfig + ROI of this data source that should be used during processing. + exclude_times: list of list of int + Which time frame intervals within the given ROI should be excluded. + Expects a list of two-element lists, each defining a range of frames. + """ + datafile = attr.ib(converter=ensure_cls(DataFileConfig), default=None) + tracksfile = attr.ib(type=str, default=None) + divisionsfile = attr.ib(type=str, default=None) + daughtersfile = attr.ib(type=str, default=None) db_name = attr.ib(type=str, default=None) gt_db_name = attr.ib(type=str, default=None) gt_db_name_polar = attr.ib(type=str, default=None) voxel_size = attr.ib(type=List[int], default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + exclude_times = attr.ib(type=List[List[int]], + validator=attr.validators.optional( + [_list_int_list_validator, + _check_nested_nd_shape(2)]), + default=None) + + def __attrs_post_init__(self): + assert (self.datafile is not None or + self.db_name is not None, + "please specify either a file source (datafile) " + "or a database source (db_name)") diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index b275bde..5c194fa 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -1,3 +1,5 @@ +"""Configuration used for evaluation +""" from typing import List import attr @@ -8,7 +10,50 @@ @attr.s(kw_only=True) -class EvaluateParametersConfig: +class EvaluateParametersTrackingConfig: + """Defines a set of evaluation parameters for Tracking + + Notes + ----- + This set, in combination with the solving step parameters, + identifies a solution uniquely. Only solutions with the same + evaluation parameters (but varying solving parameters) can be + compared directly. + Allows, for instance, to evaluate the model in different ROIs. + + Attributes + ---------- + matching_threshold: int + How far can a GT annotation and a predicted object be apart but + still be matched to each other. + roi: DataROIConfig + Which ROI should be evaluated? + validation_score: bool + Should the validation score be computed (additional metric) + window_size: int + What is the maximum window size for which the fraction of + error-free tracklets should be computed? + filter_polar_bodies: bool + Should polar bodies be removed from the computed tracks? + Requires cell state classifier predictions, removes objects with + a high polar body score from tracks, does not load GT polar + bodies. + filter_polar_bodies_key: str + If polar bodies should be filtered, which attribute in database + node collection should be used + filter_short_tracklets_len: int + If positive, remove all tracks shorter than this many objects + ignore_one_off_div_errors: bool + Division annotations are often slightly imprecise. Due to the + limited temporal resolution the exact moment a division happens + cannnot always be determined accuratly. If the predicted division + happens 1 frame before or after an annotated one, does not count + it as an error. + fn_div_count_unconnected_parent: bool + If the parent of the mother cell of a division is missing, should + this count as a division error (aside from the already counted FN + edge error) + """ matching_threshold = attr.ib(type=int) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) validation_score = attr.ib(type=bool, default=False) @@ -53,24 +98,73 @@ def query(self): @attr.s(kw_only=True) -class EvaluateConfig: - job = attr.ib(converter=ensure_cls(JobConfig), default=None) +class _EvaluateConfig: + """Base class for evaluation configuration + + Attributes + ---------- + job: JobConfig + HPC cluster parameters, default constructed (executed locally) + if not supplied + """ + job = attr.ib(converter=ensure_cls(JobConfig), + default=attr.Factory(JobConfig)) @attr.s(kw_only=True) -class EvaluateTrackingConfig(EvaluateConfig): +class EvaluateTrackingConfig(_EvaluateConfig): + """Defines specialized class for configuration of tracking evaluation + + Attributes + ---------- + from_scratch: bool + Recompute solution even if it already exists + parameters: EvaluateParametersTrackingConfig + Which evaluation parameters to use + """ from_scratch = attr.ib(type=bool, default=False) - parameters = attr.ib(converter=ensure_cls(EvaluateParametersConfig)) + parameters = attr.ib(converter=ensure_cls(EvaluateParametersTrackingConfig)) @attr.s(kw_only=True) class EvaluateParametersCellCycleConfig: + """Defines a set of evaluation parameters for the cell state classifier + + Attributes + ---------- + matching_threshold: int + How far can a GT annotation and a predicted object be apart but + still be matched to each other. + roi: DataROIConfig + Which ROI should be evaluated? + """ matching_threshold = attr.ib() roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) @attr.s(kw_only=True) -class EvaluateCellCycleConfig(EvaluateConfig): +class EvaluateCellCycleConfig(_EvaluateConfig): + """Defines specialized class for configuration of cell state evaluation + + Attributes + ---------- + max_samples: int + maximum number of samples to evaluate, deprecated + metric: str + which metric to use, deprecated + one_off: bool + Check for one-frame-off divisions (and do not count them as errors) + prob_threshold: float + Ignore predicted objects with a lower score + dry_run: bool + Do not write results to database + find_fn: bool + Do not run normal evaluation but locate missing objects/FN + force_eval: bool + Run evaluation even if results exist already, deprecated + parameters: EvaluateParametersCellCycleConfig + Which evaluation parameters to use + """ max_samples = attr.ib(type=int) metric = attr.ib(type=str) one_off = attr.ib(type=bool) diff --git a/linajea/config/extract.py b/linajea/config/extract.py index 24a77be..ae76d1e 100644 --- a/linajea/config/extract.py +++ b/linajea/config/extract.py @@ -1,14 +1,23 @@ -import attr +"""Configuration for extract edges step + +After all object/node candidates have been predicted, for each node +check neighborhood in previous frame for potential parent nodes and +generate edge candidates. Each candidate is scores by the distance +between the location predicted by the movement vector and its actual +position. +""" from typing import List, Dict +import attr + from .job import JobConfig from .utils import ensure_cls -def edge_move_converter(): +def _edge_move_converter(): def converter(val): if isinstance(val, int): - return {0: val} + return {-1: val} else: return val return converter @@ -16,9 +25,31 @@ def converter(val): @attr.s(kw_only=True) class ExtractConfig: + """Defines configuration of extract edges step + + Attributes + ---------- + edge_move_threshold: dict of int: int or int + How far can a cell move in one frame? If scalar, same value for + all frames, otherwise dict of frames to thresholds. For each + node use entry that is higher but closest. + block_size: list of int + Large data samples have to be processed in blocks, defines + size of each block + job: JobConfig + HPC cluster parameters, default constructed (executed locally) + if not supplied + context: list of int + Size of context by which block is grown, to ensure consistent + solution along borders + use_pv_distance: bool + Use distance to location predicted by movement vector to look + for closest neighbors, recommended + """ edge_move_threshold = attr.ib(type=Dict[int, int], - converter=edge_move_converter()) + converter=_edge_move_converter()) block_size = attr.ib(type=List[int]) - job = attr.ib(converter=ensure_cls(JobConfig)) + job = attr.ib(converter=ensure_cls(JobConfig), + default=attr.Factory(JobConfig)) context = attr.ib(type=List[int], default=None) use_pv_distance = attr.ib(type=bool, default=False) diff --git a/linajea/config/general.py b/linajea/config/general.py index 8793d0f..f005d1b 100644 --- a/linajea/config/general.py +++ b/linajea/config/general.py @@ -1,3 +1,8 @@ +"""General configuration parameters + +Parameters that are used in all steps, e.g., were is the experimental setup +stored, which database host to use, which logging level should be used. +""" import os import attr @@ -5,18 +10,38 @@ @attr.s(kw_only=True) class GeneralConfig: + """Defines general configuration parameters + + Attributes + ---------- + setup_dir: str + Where the experiment data is/should be stored (e.g. model + checkpoints, configuration files etc.), can be None if working + with existing CandidateDatabase (no training/prediction) + db_host: str + Address of the mongodb server, by default a local server is assumed + singularity_image: str, optional + Which singularity image to use, deprecated + sparse: bool + Is the ground truth sparse (not every instance is annotated) + logging: int + Which python logging level should be used: + (10 - DEBUG, 20 - INFO, 30 - WARNING, 40 - ERROR) + seed: int + Which random seed to use, for replication of experiments, + experimental, not used everywhere yet + """ # set via post_init hook - # setup = attr.ib(type=str) setup_dir = attr.ib(type=str, default=None) - db_host = attr.ib(type=str) - # sample = attr.ib(type=str) - db_name = attr.ib(type=str, default=None) + db_host = attr.ib(type=str, default="mongodb://localhost:27017") singularity_image = attr.ib(type=str, default=None) sparse = attr.ib(type=bool, default=True) two_frame_edges = attr.ib(type=bool, default=False) tag = attr.ib(type=str, default=None) seed = attr.ib(type=int) logging = attr.ib(type=int) + subsampling = attr.ib(type=int, default=None) + subsampling_seed = attr.ib(type=int, default=None) def __attrs_post_init__(self): if self.setup_dir is not None: diff --git a/linajea/config/job.py b/linajea/config/job.py index 109f4a6..90253a1 100644 --- a/linajea/config/job.py +++ b/linajea/config/job.py @@ -1,10 +1,35 @@ +"""Configuration used to define where and how a job is to be executed +""" import attr @attr.s class JobConfig: - num_workers = attr.ib(type=int) - queue = attr.ib(type=str) + """Defines the configuration for where and how a job should be executed + + Attributes + ---------- + num_workers: int + How many processes/workers to use + queue: str + Which HPC job queue to use (for lsf) + lab: + Under which budget/account the job should run (for lsf) + singularity_image: str + Which singularity image should be used, deprecated + run_on: str + on which type of (hpc) system should the job be run + one of: local, lsf, slurm, gridengine + tested: local, lsf + experimental: slurm, gridengine + + """ + num_workers = attr.ib(type=int, default=1) + queue = attr.ib(type=str, default="local") lab = attr.ib(type=str, default=None) singularity_image = attr.ib(type=str, default=None) - local = attr.ib(type=bool, default=False) + run_on = attr.ib(type=str, default="local", + validator=attr.validators.in_(["local", + "lsf", + "slurm", + "gridengine"])) diff --git a/linajea/config/optimizer.py b/linajea/config/optimizer.py index 20854de..0d9897f 100644 --- a/linajea/config/optimizer.py +++ b/linajea/config/optimizer.py @@ -1,57 +1,173 @@ -import attr +"""Configuration used to define the optimizer used during training + +Which options are available depends on the framework used, +usually pytorch or tensorflow +""" from typing import List +import attr + from .utils import ensure_cls @attr.s(kw_only=True) class OptimizerTF1ArgsConfig: + """Defines the positional (args) arguments for a tf1 optimizer + + Attributes + ---------- + weight_decay: float + Rate of l2 weight decay + learning_rate: float + Which learning rate to use, fixed value + momentum: float + Which momentum to use, fixed value + """ weight_decay = attr.ib(type=float, default=None) learning_rate = attr.ib(type=float, default=None) momentum = attr.ib(type=float, default=None) @attr.s(kw_only=True) class OptimizerTF1KwargsConfig: + """Defines the keyword (kwargs) arguments for a tf1 optimizer + + If not set, framework defaults are used. + + Attributes + ---------- + learning_rate: float + Which learning rate to use, fixed value + beta1: float + Beta1 parameter of Adam optimizer + beta2: float + Beta2 parameter of Adam optimzer + epsilon: float + Epsilon parameter of Adam optimizer + + Notes + ----- + Extend list of attributes for other optimizers + """ learning_rate = attr.ib(type=float, default=None) beta1 = attr.ib(type=float, default=None) beta2 = attr.ib(type=float, default=None) epsilon = attr.ib(type=float, default=None) - # to be extended for other (not Adam) optimizers @attr.s(kw_only=True) class OptimizerTF1Config: + """Defines which tensorflow1 optimizer to use + + Attributes + ---------- + optimizer: str + Name of tf1 optimizer class to use + lr_schedule: str + What type of learning rate schedule to use, deprecated + args: OptimizerTF1ArgsConfig + Which args should be passed to optimizer constructor + kwargs: OptimizerTF1KwargsConfig + Which kwargs should be passed to optimizer constructor + + Notes + ----- + Example on how to create optimzier: + opt = getattr(tf.train, config.optimizerTF1.optimizer)( + *config.optimizerTF1.get_args(), + **config.optimizerTF1.get_kwargs()) + """ optimizer = attr.ib(type=str, default="AdamOptimizer") lr_schedule = attr.ib(type=str, default=None) args = attr.ib(converter=ensure_cls(OptimizerTF1ArgsConfig)) kwargs = attr.ib(converter=ensure_cls(OptimizerTF1KwargsConfig)) def get_args(self): + """Get list of positional parameters""" return [v for v in attr.astuple(self.args) if v is not None] def get_kwargs(self): + """Get dict of keyword parameters""" return {a:v for a,v in attr.asdict(self.kwargs).items() if v is not None} @attr.s(kw_only=True) class OptimizerTF2KwargsConfig: + """Defines the keyword (kwargs) arguments for a tf2 optimizer + + If not set, framework defaults are used. + + Attributes + ---------- + beta_1: float + Beta1 parameter of Adam optimizer + beta_2: float + Beta2 parameter of Adam optimzer + epsilon: float + Epsilon parameter of Adam optimizer + learning_rate: float + Which learning rate to use, fixed value + momentum: float + Which momentum to use, fixed value + + Notes + ----- + Extend list of attributes for other optimizers + """ beta_1 = attr.ib(type=float, default=None) beta_2 = attr.ib(type=float, default=None) epsilon = attr.ib(type=float, default=None) learning_rate = attr.ib(type=float, default=None) momentum = attr.ib(type=float, default=None) - # to be extended for other (not Adam) optimizers @attr.s(kw_only=True) class OptimizerTF2Config: + """Defines which tensorflow1 optimizer to use + + Attributes + ---------- + optimizer: str + Name of tf2 optimizer class to use + kwargs: OptimizerTF2KwargsConfig + Which kwargs should be passed to optimizer constructor + + Notes + ----- + Example on how to create optimzier: + opt = getattr(tf.keras.optimizers, config.optimizerTF2.optimizer)( + **config.optimizerTF2.get_kwargs()) + """ optimizer = attr.ib(type=str, default="Adam") kwargs = attr.ib(converter=ensure_cls(OptimizerTF2KwargsConfig)) def get_kwargs(self): + """Get dict of keyword parameters""" return {a:v for a,v in attr.asdict(self.kwargs).items() if v is not None} @attr.s(kw_only=True) class OptimizerTorchKwargsConfig: + """Defines the keyword (kwargs) arguments for a pytorch optimizer + + Attributes + ---------- + betas: list of float + Beta parameters of Adam optimizer + eps: float + Epsilon parameter of Adam optimizer + lr: float + Which learning rate to use, fixed value + amsgrad: bool + Should the amsgrad extension to Adam be used? + momentum: float + Which momentum to use, fixed value + nesterov: bool + Should Nesterov momentum be used? + weight_decay: float + Rate of l2 weight decay + + Notes + ----- + Extend list of attributes for other optimizers + """ betas = attr.ib(type=List[float], default=None) eps = attr.ib(type=float, default=None) lr = attr.ib(type=float, default=None) @@ -59,12 +175,27 @@ class OptimizerTorchKwargsConfig: momentum = attr.ib(type=float, default=None) nesterov = attr.ib(type=bool, default=None) weight_decay = attr.ib(type=float, default=None) - # to be extended for other (not Adam) optimizers @attr.s(kw_only=True) class OptimizerTorchConfig: + """Defines which pytorch optimizer to use + + Attributes + ---------- + optimizer: str + Name of torch optimizer class to use + kwargs: OptimizerTorchKwargsConfig + Which kwargs should be passed to optimizer constructor + + Notes + ----- + Example on how to create optimzier: + opt = getattr(torch.optim, config.optimizerTorch.optimizer)( + model.parameters(), **config.optimizerTorch.get_kwargs()) + """ optimizer = attr.ib(type=str, default="Adam") kwargs = attr.ib(converter=ensure_cls(OptimizerTorchKwargsConfig)) def get_kwargs(self): + """Get dict of keyword parameters""" return {a:v for a,v in attr.asdict(self.kwargs).items() if v is not None} diff --git a/linajea/config/predict.py b/linajea/config/predict.py index 2939cae..f2992ca 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -1,16 +1,60 @@ +"""Configuration used to define prediction parameters +""" import attr + +from .augment import NormalizeConfig from .job import JobConfig from .utils import ensure_cls @attr.s(kw_only=True) -class PredictConfig: - job = attr.ib(converter=ensure_cls(JobConfig)) +class _PredictConfig: + """Defines base class for general prediction parameters + + Attributes + ---------- + job: JobConfig + HPC cluster parameters, default constructed (executed locally) + if not supplied + path_to_script: str + Which script should be called for prediction, deprecated + use_swa: bool + If also used during training, should the Stochastic Weight Averaging + model be used for prediction, check TrainConfig for more information + normalization: NormalizeConfig + How input data should be normalized, if not set + train.normalization is used + """ + job = attr.ib(converter=ensure_cls(JobConfig), + default=attr.Factory(JobConfig)) path_to_script = attr.ib(type=str) use_swa = attr.ib(type=bool, default=None) + normalization = attr.ib(converter=ensure_cls(NormalizeConfig), + default=None) @attr.s(kw_only=True) -class PredictTrackingConfig(PredictConfig): +class PredictTrackingConfig(_PredictConfig): + """Defines specialized class for configuration of tracking prediction + + Attributes + ---------- + path_to_script_db_from_zarr: str + Which script to use to write from an already predicted volume + directly to database, instead of predicting again + write_to_zarr: bool + Write output to zarr volume, e.g. for visualization + write_to_db: bool + Write output to database, required for next steps + write_db_from_zarr: bool + Use previously computed zarr to write to database + no_db_access: bool + If write_to_zarr is used, do not access database + (otherwise used to store which blocks have already been predicted) + processes_per_worker: int + How many processes each worker can use (for parallel data loading) + output_zarr_prefix: str + Where zarr should be stored + """ path_to_script_db_from_zarr = attr.ib(type=str, default=None) write_to_zarr = attr.ib(type=bool, default=False) write_to_db = attr.ib(type=bool, default=True) @@ -20,6 +64,7 @@ class PredictTrackingConfig(PredictConfig): output_zarr_prefix = attr.ib(type=str, default=".") def __attrs_post_init__(self): + """verify that combination of supplied parameters is valid""" assert self.write_to_zarr or self.write_to_db, \ "prediction not written, set write_to_zarr or write_to_db to true!" assert not self.write_db_from_zarr or \ @@ -29,7 +74,20 @@ def __attrs_post_init__(self): "no_db_access can only be set if no data is written to db (it then disables db done checks for write to zarr)" @attr.s(kw_only=True) -class PredictCellCycleConfig(PredictConfig): +class PredictCellCycleConfig(_PredictConfig): + """Defines specialized class for configuration of cell state prediction + + Attributes + ---------- + batch_size: int + How many samples should be in each mini-batch + max_samples: int + Limit how many samples to predict, all if set to None + prefix: str + If results is stored in database, define prefix for attribute name + test_time_reps: int + Do test time augmentation, how many repetitions? + """ batch_size = attr.ib(type=int) max_samples = attr.ib(type=int, default=None) prefix = attr.ib(type=str, default="") diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 550958e..031b08d 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -1,22 +1,30 @@ -import attr +"""Configuration used to define parameters for ILP solver +""" + import itertools import logging -import os import random from typing import List +import attr + from .data import DataROIConfig from .job import JobConfig from .utils import (ensure_cls, - ensure_cls_list) + load_config) logger = logging.getLogger(__name__) -def convert_solve_params_list(): +def _convert_solve_params_list(): + """Auxiliary setup function to distinguish between + minimal and non-minimal ILP parameters""" def converter(vals): if vals is None: return None + if isinstance(vals, str) and vals.endswith(".toml"): + tmp_config = load_config(vals) + vals = tmp_config["solve"]["parameters"] if not isinstance(vals, list): vals = [vals] converted = [] @@ -34,7 +42,9 @@ def converter(vals): return converter -def convert_solve_search_params(): +def _convert_solve_search_params(): + """Auxiliary setup function to distinguish between + minimal and non-minimal ILP parameters""" def converter(vals): if vals is None: return None @@ -52,6 +62,41 @@ def converter(vals): @attr.s(kw_only=True) class SolveParametersMinimalConfig: + """Defines a set of ILP hyperparameters + + Attributes + ---------- + track_cost, weight_node_score, selection_constant, weight_division, + division_constant, weight_child, weight_continuation, + weight_edge_score: float + main ILP hyperparameters + cell_cycle_key: str + key defining which cell cycle classifier to use + block_size: list of int + ILP is solved in blocks, defines size of each block + context: list of int + Size of context by which block is grown, to ensure consistent + solution along borders + max_cell_move: int + How far a cell can move in one frame, cells closer than this + value to the border do not have to pay certain costs + (by default edge_move_threshold from extract is used) + roi: DataROI + Size of the data sample that is being "solved" + feature_func: str + Optional function that is applied to node and edge scores + before incorporating them into the cost function. + One of ["noop", "log", "square"] + val: bool + Is this set of parameters part of the validation + parameter search or does it represent a test result? + (if database is used once for testing and once for validation + as part of cross-validation) + tag: str + To automatically tag e.g. ssvm/greedy solutions + + + """ track_cost = attr.ib(type=float) weight_node_score = attr.ib(type=float) selection_constant = attr.ib(type=float) @@ -63,18 +108,31 @@ class SolveParametersMinimalConfig: cell_cycle_key = attr.ib(type=str, default=None) block_size = attr.ib(type=List[int]) context = attr.ib(type=List[int]) - # max_cell_move: currently use edge_move_threshold from extract max_cell_move = attr.ib(type=int, default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) feature_func = attr.ib(type=str, default="noop") val = attr.ib(type=bool, default=False) + tag = attr.ib(type=str, default=None) def valid(self): + """Get all valid attributes + + Returns + ------- + Dict with all parameters that are not None + """ return {key: val for key, val in attr.asdict(self).items() if val is not None} def query(self): + """Get attributes for (querying database) + + Returns + ------- + Dict with all valid parameters and all invalid parameters + set to "exist=False" + """ params_dict_valid = self.valid() params_dict_none = {key: {"$exists": False} for key, val in attr.asdict(self).items() @@ -85,25 +143,50 @@ def query(self): @attr.s(kw_only=True) class SolveParametersMinimalSearchConfig: + """Defines ranges/sets of ILP hyperparameters for a parameter search + + Can be used for both random search (search by selecting random + values within given range) and grid search (search by + cartesian product of given values per parameter) + + Notes + ----- + For description of main attributes see SolveParametersMinimalConfig + + Attributes + ---------- + num_configs: int + How many sets of parameters to check. + For random search: select this many sets of random values + For grid search: shuffle cartesian product of parameters and + take the num_configs first ones + """ track_cost = attr.ib(type=List[float]) weight_node_score = attr.ib(type=List[float]) selection_constant = attr.ib(type=List[float]) - weight_division = attr.ib(type=List[float]) + weight_division = attr.ib(type=List[float], default=None) division_constant = attr.ib(type=List[float]) - weight_child = attr.ib(type=List[float]) - weight_continuation = attr.ib(type=List[float]) + weight_child = attr.ib(type=List[float], default=None) + weight_continuation = attr.ib(type=List[float], default=None) weight_edge_score = attr.ib(type=List[float]) cell_cycle_key = attr.ib(type=str, default=None) block_size = attr.ib(type=List[List[int]]) context = attr.ib(type=List[List[int]]) - # max_cell_move: currently use edge_move_threshold from extract max_cell_move = attr.ib(type=List[int], default=None) feature_func = attr.ib(type=List[str], default=["noop"]) - num_configs = attr.ib(type=int, default=None) val = attr.ib(type=List[bool], default=[True]) + num_configs = attr.ib(type=int, default=None) + @attr.s(kw_only=True) class SolveParametersNonMinimalConfig: + """Defines ILP hyperparameters + + Notes + ----- + old set of parameters, not used anymore, for backwards compatibility + for basic info see SolveParametersMinimalConfig + """ cost_appear = attr.ib(type=float) cost_disappear = attr.ib(type=float) cost_split = attr.ib(type=float) @@ -121,7 +204,6 @@ class SolveParametersNonMinimalConfig: prefix = attr.ib(type=str, default=None) block_size = attr.ib(type=List[int]) context = attr.ib(type=List[int]) - # max_cell_move: currently use edge_move_threshold from extract max_cell_move = attr.ib(type=int, default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) @@ -141,6 +223,13 @@ def query(self): @attr.s(kw_only=True) class SolveParametersNonMinimalSearchConfig: + """Defines ranges/sets of ILP hyperparameters for a parameter search + + Notes + ----- + old set of parameters, not used anymore, for backwards compatibility + for basic info see SolveParametersMinimalSearchConfig + """ cost_appear = attr.ib(type=List[float]) cost_disappear = attr.ib(type=List[float]) cost_split = attr.ib(type=List[float]) @@ -162,6 +251,44 @@ class SolveParametersNonMinimalSearchConfig: num_configs = attr.ib(type=int, default=None) def write_solve_parameters_configs(parameters_search, non_minimal, grid): + """Create list of ILP hyperparameter sets based on configuration + + Args + ---- + parameters_search: SolveParametersMinimalSearchConfig + Parameter search object that is used to create list of + individual sets of parameters + non_minimal: bool + Create minimal (new) or non minimal (old, deprecated) set of + parameters + grid: bool + Do grid search or random search + + Returns + ------- + List of ILP hyperparameter configurations + + + Notes + ----- + Random search: + How random values are determined depends on respective type of + values, number of combinations determined by num_configs + Not a list or list with single value: + interpreted as single value that is selected + List of str or more than two values: + interpreted as discrete options + List of two lists: + Sample outer list discretely. If inner list contains + strings, sample discretely again; if inner list contains + numbers, sample uniformly from range + List of two numbers: + Sample uniformly from range + Grid search: + Perform some type cleanup and compute cartesian product with + itertools.product. If num_configs is set, shuffle list and take + the num_configs first ones. + """ params = {k:v for k,v in attr.asdict(parameters_search).items() if v is not None} @@ -230,12 +357,57 @@ def write_solve_parameters_configs(parameters_search, non_minimal, grid): @attr.s(kw_only=True) class SolveConfig: - job = attr.ib(converter=ensure_cls(JobConfig)) + """Defines configuration for ILP solving step + + Attributes + ---------- + job: JobConfig + HPC cluster parameters, default constructed (executed locally) + if not supplied + from_scratch: bool + If solution should be recomputed if it already exists + parameters: SolveParametersMinimalConfig + Fixed set of ILP parameters + parameters_search_grid, parameters_search_random: SolveParametersMinimalSearchConfig + Ranges/sets per ILP parameter to create parameter search + non_minimal: bool + If old (non-minimal) parameters should be used, deprecated + greedy: bool + Do not use ILP for solving, greedy nearest neighbor tracking + write_struct_svm: str + If not None, do not solve but write files required for StructSVM + parameter search + check_node_close_to_roi: bool + If set, nodes close to roi border do not pay certain costs + (as they can move outside of the field of view) + add_node_density_constraints: bool + Prevent multiple nodes within certain range, useful if size of + nuclei changes drastically over time, deprecated + timeout: int + Time the solver has to find a solution + masked_nodes: str + Tag that can be used to mask certain nodes (i.e. discard them), + deprecated + clip_low_score: float + Discard nodes with score lower than this value; + Only useful if lower than threshold used during prediction + grid_search, random_search: bool + If grid and/or random search over ILP parameters should be + performed + + Notes + ----- + The post init attrs function handles all the parameter setup, e.g. + creating the parameter search configurations on the fly, if and + which kind of search to perform etc. + """ + job = attr.ib(converter=ensure_cls(JobConfig), + default=attr.Factory(JobConfig)) from_scratch = attr.ib(type=bool, default=False) - parameters = attr.ib(converter=convert_solve_params_list(), default=None) - parameters_search_grid = attr.ib(converter=convert_solve_search_params(), + parameters = attr.ib(converter=_convert_solve_params_list(), default=None) + parameters_search_grid = attr.ib(converter=_convert_solve_search_params(), default=None) - parameters_search_random = attr.ib(converter=convert_solve_search_params(), + parameters_search_random = attr.ib(converter=_convert_solve_search_params(), default=None) non_minimal = attr.ib(type=bool, default=False) greedy = attr.ib(type=bool, default=False) @@ -258,6 +430,14 @@ def __attrs_post_init__(self): if self.grid_search or self.random_search: assert self.grid_search != self.random_search, \ "choose either grid or random search!" + assert self.parameters is None, \ + ("overwriting explicit solve parameters with grid/random search " + "parameters not supported. For search please either (1) " + "precompute search parameters (e.g. using write_config_files.py) " + "and set solve.parameters to point to resulting file or (2) " + "let search parameters be created automatically by setting " + "solve.grid/random_search to true (only supported when using " + "the getNextInferenceData facility to loop over data samples)") if self.parameters is not None: logger.warning("overwriting explicit solve parameters with " "grid/random search parameters!") @@ -285,8 +465,8 @@ def __attrs_post_init__(self): "weight_child": 0, "weight_continuation": 0, "weight_edge_score": 0, - "block_size": [15, 512, 512, 712], - "context": [2, 100, 100, 100] + "block_size": [-1, -1, -1, -1], + "context": [-1, -1, -1, -1] } if self.parameters[0].cell_cycle_key is not None: config_vals['cell_cycle_key'] = self.parameters[0].cell_cycle_key diff --git a/linajea/config/tracking_config.py b/linajea/config/tracking_config.py index 393ef1b..fce6f3d 100644 --- a/linajea/config/tracking_config.py +++ b/linajea/config/tracking_config.py @@ -1,6 +1,14 @@ +"""Main configuration used for a tracking experiment + +Aggregates the sub configuration modules into a large configuration +file. Most parameters are optional, depending on which step of the +pipeline should be computed. If parameters necessary for a particular +step are not present, an error will be thrown. +""" import attr +import pymongo -from linajea import load_config +from .data import DataROIConfig from .evaluate import EvaluateTrackingConfig from .extract import ExtractConfig from .general import GeneralConfig @@ -9,45 +17,119 @@ OptimizerTorchConfig) from .predict import PredictTrackingConfig from .solve import SolveConfig -# from .test import TestTrackingConfig from .train_test_validate_data import (InferenceDataTrackingConfig, TestDataTrackingConfig, TrainDataTrackingConfig, ValidateDataTrackingConfig) from .train import TrainTrackingConfig from .unet_config import UnetConfig -from .utils import ensure_cls -# from .validate import ValidateConfig +from .utils import (ensure_cls, + load_config) @attr.s(kw_only=True) class TrackingConfig: + """Defines the configuration for a tracking experiment + + Attributes + ---------- + path: str + Path to the config file, do not set, will be overwritten and + set automatically + general: GeneralConfig + General configuration parameters + model: UnetConfig + Parameters defining the use U-Net + optimizerTF1: OptimizerTF1Config + optimizerTF2: OptimizerTF2Config + optimizerTorch: OptimizerTorchConfig + Parameters defining the optimizer used during training + train: TrainTrackingConfig + Parameters defining the general training parameters, e.g. + augmentation, how many steps + train_data: TrainDataTrackingConfig + test_data: TestDataTrackingConfig + validate_data: ValidateDataTrackingConfig + inference_data: InferenceDataTrackingConfig + Which data set to use for training/validation/test/inference + predict: PredictTrackingConfig + Prediction parameters + extract: ExtractConfig + Parameters defining how edges should be extracted from Candidates + solve: SolveConfig + ILP weights and other parameters defining solving step + evaluate: EvaluateTrackingConfig + Evaluation parameters + """ path = attr.ib(type=str) general = attr.ib(converter=ensure_cls(GeneralConfig)) - model = attr.ib(converter=ensure_cls(UnetConfig)) - optimizerTF1 = attr.ib(converter=ensure_cls(OptimizerTF1Config), default=None) - optimizerTF2 = attr.ib(converter=ensure_cls(OptimizerTF2Config), default=None) - optimizerTorch = attr.ib(converter=ensure_cls(OptimizerTorchConfig), default=None) - train = attr.ib(converter=ensure_cls(TrainTrackingConfig)) - train_data = attr.ib(converter=ensure_cls(TrainDataTrackingConfig)) - test_data = attr.ib(converter=ensure_cls(TestDataTrackingConfig)) - validate_data = attr.ib(converter=ensure_cls(ValidateDataTrackingConfig)) - inference = attr.ib(converter=ensure_cls(InferenceDataTrackingConfig), default=None) - predict = attr.ib(converter=ensure_cls(PredictTrackingConfig)) - extract = attr.ib(converter=ensure_cls(ExtractConfig)) - solve = attr.ib(converter=ensure_cls(SolveConfig)) - evaluate = attr.ib(converter=ensure_cls(EvaluateTrackingConfig)) + model = attr.ib(converter=ensure_cls(UnetConfig), default=None) + optimizerTF1 = attr.ib(converter=ensure_cls(OptimizerTF1Config), + default=None) + optimizerTF2 = attr.ib(converter=ensure_cls(OptimizerTF2Config), + default=None) + optimizerTorch = attr.ib(converter=ensure_cls(OptimizerTorchConfig), + default=None) + train = attr.ib(converter=ensure_cls(TrainTrackingConfig), default=None) + train_data = attr.ib(converter=ensure_cls(TrainDataTrackingConfig), + default=None) + test_data = attr.ib(converter=ensure_cls(TestDataTrackingConfig), + default=None) + validate_data = attr.ib(converter=ensure_cls(ValidateDataTrackingConfig), + default=None) + inference_data = attr.ib(converter=ensure_cls(InferenceDataTrackingConfig), + default=None) + predict = attr.ib(converter=ensure_cls(PredictTrackingConfig), default=None) + extract = attr.ib(converter=ensure_cls(ExtractConfig), default=None) + solve = attr.ib(converter=ensure_cls(SolveConfig), default=None) + evaluate = attr.ib(converter=ensure_cls(EvaluateTrackingConfig), + default=None) @classmethod def from_file(cls, path): + """Construct TrackingConfig object from path to config file + + Called as TrackingConfig.from_file(path_to_config_file) + """ config_dict = load_config(path) config_dict["path"] = path return cls(**config_dict) # type: ignore def __attrs_post_init__(self): + """Validate supplied parameters + + At most one optimizer configuration can be supplied + If use_swa is not set for prediction step, use value from train + If normalization is not set for prediction step, use value from train + Verify that ROI is set at some level for each data source + """ assert (int(bool(self.optimizerTF1)) + int(bool(self.optimizerTF2)) + - int(bool(self.optimizerTorch))) == 1, \ - "please specify exactly one optimizer config (tf1, tf2, torch)" + int(bool(self.optimizerTorch))) <= 1, \ + "please specify only one optimizer config (tf1, tf2, torch)" - if self.predict.use_swa is None: + if self.predict is not None and \ + self.train is not None and \ + self.predict.use_swa is None: self.predict.use_swa = self.train.use_swa + + dss = [] + dss += self.train_data.data_sources if self.train_data is not None else [] + dss += self.test_data.data_sources if self.test_data is not None else [] + dss += self.validate_data.data_sources if self.validate_data is not None else [] + dss += [self.inference_data.data_source] if self.inference_data is not None else [] + for sample in dss: + if sample.roi is None: + try: + client = pymongo.MongoClient(host=self.general.db_host) + db = client[sample.db_name] + query_result = db["db_meta_info"].find_one() + sample.roi = DataROIConfig(**query_result["roi"]) + except Exception as e: + raise RuntimeError( + "please specify roi for data! not set and unable to " + "determine it automatically based on given db (db_name) (%s)" % e) + + if self.predict is not None: + if self.predict.normalization is None and \ + self.train is not None: + self.predict.normalization = self.train.normalization diff --git a/linajea/config/train.py b/linajea/config/train.py index c727dd4..3851707 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -1,8 +1,12 @@ -import attr +"""Configuration used to define model training parameter +""" from typing import Dict, List +import attr + from .augment import (AugmentTrackingConfig, - AugmentCellCycleConfig) + AugmentCellCycleConfig, + NormalizeConfig) from .train_test_validate_data import TrainDataTrackingConfig from .job import JobConfig from .utils import ensure_cls @@ -18,7 +22,48 @@ def converter(val): @attr.s(kw_only=True) -class TrainConfig: +class _TrainConfig: + """Defines base class for general training parameters + + Attributes + ---------- + path_to_script: str + Path to the training python script to be used + (can e.g. be dependent on the type of data) + job: JobConfig + HPC cluster parameters + cache_size: int + How many batches to cache/precompute in parallel + max_iterations: int + For how many steps to train + checkpoint_stride, snapshot_stride, profiling_stride: int + After how many steps a checkpoint (of trained model) or + snapshot (of input and output data for one step) should be + stored or profling information printed + use_auto_mixed_precision: bool + Use automatic mixed precision, reduces memory consumption, + should not impact final performance + use_swa: bool + Use Stochastic Weight Averaging, in some preliminary tests we + observed improvements for the tracking networks but not for + the cell cycle networks. Cheap to compute. Keeps two copies of + the model weights in memory, one is updated as usual, the other + is a weighted average of the main weights every x steps. + Has to be supported by the respective gunpowder train node. + swa_every_it: bool + Compute SWA every step (after swa_start_it) + swa_start_it: int + Start computing SWA after this step, network should be converged + swa_freq_it: int + Compute SWA every n-th step, after swa_start_it + use_grad_norm: bool + Use gradient clipping based on the gradient norm + val_log_step: int + Interleave a validation step every n-th training step, + has to be supported by the training script + normalization: NormalizeConfig + Parameters defining which data normalization type to use + """ path_to_script = attr.ib(type=str) job = attr.ib(converter=ensure_cls(JobConfig)) cache_size = attr.ib(type=int) @@ -34,6 +79,7 @@ class TrainConfig: swa_freq_it = attr.ib(type=int, default=None) use_grad_norm = attr.ib(type=bool, default=False) val_log_step = attr.ib(type=int, default=None) + normalization = attr.ib(converter=ensure_cls(NormalizeConfig), default=None) def __attrs_post_init__(self): if self.use_swa: @@ -42,22 +88,65 @@ def __attrs_post_init__(self): @attr.s(kw_only=True) -class TrainTrackingConfig(TrainConfig): - # (radius for binary map -> *2) (optional) +class TrainTrackingConfig(_TrainConfig): + """Defines specialized class for configuration of tracking training + + Attributes + ---------- + parent_radius: list of float + Radius around each cell in which GT movement/parent vectors + are drawn, in world units, one value per dimension + (radius for binary map -> *2 to get diameter, + optional if annotations contain radius/use_radius = True) + move_radius: float + Extend ROI by this much context to ensure all parent cells are + inside the ROI, in world units + rasterize_radius: list of float + Standard deviation of Gauss kernel used to draw Gaussian blobs + for cell indicator network, one value per dimension + (*4 to get approx. width of blob, for 5x anisotropic data set + value in z to 5 for it to be in 3 slices, + optional if annotations contain radius/use_radius = True) + augment: AugmentTrackingConfig + Defines data augmentations used during training, + see AugmentTrackingConfig + parent_vectors_loss_transition_factor: float + We transition from computing the movement/parent vector loss for + all pixels smoothly to computing it only on the peak pixels of + the cell indicator map, this value determines the speed of the + transition + parent_vectors_loss_transition_offset: int + This value marks the training step when the transition blending + factor is 0.5 + use_radius: dict of int: int + If the GT annotations contain radii, enable this to use them + instead of the fixed values defined above + cell_density: bool + Predict a pixelwise cell density value, deprecated + + """ parent_radius = attr.ib(type=List[float]) - # context to be able to get location of parents during add_parent_vectors - move_radius = attr.ib(type=float, default=None) - # (sigma for Gauss -> ~*4 (5 in z -> in 3 slices)) (optional) + move_radius = attr.ib(type=float) rasterize_radius = attr.ib(type=List[float]) augment = attr.ib(converter=ensure_cls(AugmentTrackingConfig)) parent_vectors_loss_transition_factor = attr.ib(type=float, default=0.01) parent_vectors_loss_transition_offset = attr.ib(type=int, default=20000) use_radius = attr.ib(type=Dict[int, int], default=None, converter=use_radius_converter()) - cell_density = attr.ib(default=None) + cell_density = attr.ib(type=bool, default=None) @attr.s(kw_only=True) -class TrainCellCycleConfig(TrainConfig): +class TrainCellCycleConfig(_TrainConfig): + """Defines specialized class for configuration of tracking training + + Attributes + ---------- + batch_size: int + Size of mini-batches used during training + augment: AugmentCellCycleConfig + Defines data augmentations used during cell classifier training, + see AugmentCellCycleConfig + """ batch_size = attr.ib(type=int) augment = attr.ib(converter=ensure_cls(AugmentCellCycleConfig)) diff --git a/linajea/config/train_test_validate_data.py b/linajea/config/train_test_validate_data.py index e8ee0bb..2ec67b6 100644 --- a/linajea/config/train_test_validate_data.py +++ b/linajea/config/train_test_validate_data.py @@ -1,3 +1,10 @@ +"""Configuration used to define a dataset for training/test/validation + +A dataset can consist of multiple samples (data sources). For Training +TrainData has to be defined. If the automated data selection functions +are used define ValData and TestData (support multiple samples), +otherwise define InferenceData (only a single sample, data source) +""" from copy import deepcopy import os from typing import List @@ -13,9 +20,40 @@ @attr.s(kw_only=True) -class DataConfig(): +class _DataConfig(): + """Defines a base class for the definition of a data set + + Attributes + ---------- + data_sources: list of DataSourceConfig + List of data sources, can also only have a single element + voxel_size: list of int, optional + roi: DataROIConfig, optional + group: str, optional + voxel_size, roi and group can be set on the data source level + and on the data set level. If set on the data set level, they + the same values are used for all data sources + """ data_sources = attr.ib(converter=ensure_cls_list(DataSourceConfig)) + voxel_size = attr.ib(type=List[int], default=None) + roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) + group = attr.ib(type=str, default=None) def __attrs_post_init__(self): + """Validate the supplied parameters and try to fix missing ones + + For every data source: + The ROI has to be set. + If it is not set, check if it has been set on the data set level + If yes, use this value. + If no, use the ROI of the data file + The ROI cannot be larger than the ROI of the data file + The voxel size has to be set. + If it is not set, do the same as for the ROI + The group/array to be used has to be set. + If it is not set, do the same as for the ROI + + The voxel size has to be identical for all data sources + """ for d in self.data_sources: if d.roi is None: if self.roi is None: @@ -49,14 +87,12 @@ def __attrs_post_init__(self): offset=[begin_frame, None, None, None], shape=[end_frame-begin_frame+1, None, None, None]) roi = roi.intersect(track_range_roi) - # d.roi.offset[0] = max(begin_frame, d.roi.offset[0]) - # d.roi.shape[0] = min(end_frame - begin_frame + 1, - # d.roi.shape[0]) d.roi.offset = roi.get_offset() d.roi.shape = roi.get_shape() if d.voxel_size is None: if self.voxel_size is None: d.voxel_size = d.datafile.file_voxel_size + self.voxel_size = d.voxel_size else: d.voxel_size = self.voxel_size if d.datafile.group is None: @@ -68,43 +104,110 @@ def __attrs_post_init__(self): for ds in self.data_sources), \ "data sources with varying voxel_size not supported" - voxel_size = attr.ib(type=List[int], default=None) - roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) - group = attr.ib(type=str, default=None) - @attr.s(kw_only=True) -class TrainDataTrackingConfig(DataConfig): - data_sources = attr.ib(converter=ensure_cls_list(DataSourceConfig)) +class TrainDataTrackingConfig(_DataConfig): + """Defines a specialized class for the definition of a training data set + """ + # data_sources = attr.ib(converter=ensure_cls_list(DataSourceConfig)) @data_sources.validator def _check_train_data_source(self, attribute, value): + """a train data source has to use datafiles and cannot have a database""" for ds in value: if ds.db_name is not None: raise ValueError("train data_sources must not have a db_name") @attr.s(kw_only=True) -class TestDataTrackingConfig(DataConfig): +class TestDataTrackingConfig(_DataConfig): + """Defines a specialized class for the definition of a test data set + + Attributes + ---------- + checkpoint: int + Which checkpoint of the trained model should be used? + cell_score_threshold: float + What is the minimum score of object/node candidates? + """ checkpoint = attr.ib(type=int, default=None) cell_score_threshold = attr.ib(type=float, default=None) @attr.s(kw_only=True) class InferenceDataTrackingConfig(): + """Defines a class for the definition of an inference data set + + An inference data set has only a single data source. + If the getNextInferenceData facility is used for inference, it is + set automatically based on the values for validate/test data and + the current step in the pipeline. + Otherwise it has to be set manually (and instead of validate/test) + data. + + Attributes + ---------- + data_source: DataSourceConfig + Which data source should be used? + checkpoint: int + Which checkpoint of the trained model should be used? + cell_score_threshold: float + What is the minimum score of object/node candidates? + """ data_source = attr.ib(converter=ensure_cls(DataSourceConfig)) checkpoint = attr.ib(type=int, default=None) cell_score_threshold = attr.ib(type=float, default=None) + def __attrs_post_init__(self): + """Try to fix ROI/voxel size if needed + + If a data file is set, and ROI or voxel size are not set, try + to set it based on file info + If data file is not set, and ROI or voxel size are not set and + database does not contain the respective information an error + will be thrown later in the pipeline. + """ + if self.data_source.datafile is not None: + if self.data_source.voxel_size is None: + self.data_source.voxel_size = self.data_source.datafile.file_voxel_size + if self.data_source.roi is None: + self.data_source.roi = self.data_source.datafile.file_roi @attr.s(kw_only=True) -class ValidateDataTrackingConfig(DataConfig): - checkpoints = attr.ib(type=List[int]) +class ValidateDataTrackingConfig(_DataConfig): + """Defines a specialized class for the definition of a validation data set + + Attributes + ---------- + checkpoints: list of int + Which checkpoints of the trained model should be used? + cell_score_threshold: float + What is the minimum score of object/node candidates? + + Notes + ----- + Computes the results for every checkpoint + """ + checkpoints = attr.ib(type=List[int], default=[None]) cell_score_threshold = attr.ib(type=float, default=None) @attr.s(kw_only=True) -class DataCellCycleConfig(DataConfig): +class _DataCellCycleConfig(_DataConfig): + """Defines a base class for the definition of a cell state classifier data set + + Attributes + ---------- + use_database: bool + If set, samples are read from database, not from data file + db_meta_info: DataDBMetaConfig + Identifies database to use if use_database is set + skip_predict: bool + Skip prediction step, e.g. if already computed + (enables shortcut in run script) + force_predict: bool + Enforce prediction, even if already done previously + """ use_database = attr.ib(type=bool) db_meta_info = attr.ib(converter=ensure_cls(DataDBMetaConfig), default=None) skip_predict = attr.ib(type=bool, default=False) @@ -112,24 +215,65 @@ class DataCellCycleConfig(DataConfig): @attr.s(kw_only=True) -class TrainDataCellCycleConfig(DataCellCycleConfig): +class TrainDataCellCycleConfig(_DataCellCycleConfig): + """Definition of a cell state classifier training data set + """ pass @attr.s(kw_only=True) -class TestDataCellCycleConfig(DataCellCycleConfig): +class TestDataCellCycleConfig(_DataCellCycleConfig): + """Definition of a cell state classifier test data set + + Attributes + ---------- + checkpoint: int + Which checkpoint of the trained model should be used? + prob_threshold: float + What is the minimum score of object/node candidates to be considered? + """ checkpoint = attr.ib(type=int) prob_threshold = attr.ib(type=float, default=None) @attr.s(kw_only=True) -class ValidateDataCellCycleConfig(DataCellCycleConfig): +class ValidateDataCellCycleConfig(_DataCellCycleConfig): + """Definition of a cell state classifier training data set + + Attributes + ---------- + checkpoints: list of int + Which checkpoints of the trained model should be used? + prob_threshold: list of float + What are the minimum scores of object/node candidates to be considered? + + Notes + ----- + Computes the result for every combination of checkpoints and thresholds + """ checkpoints = attr.ib(type=List[int]) prob_thresholds = attr.ib(type=List[float], default=None) @attr.s(kw_only=True) class InferenceDataCellCycleConfig(): + """Definition of a cell state classifier inference data set + + Attributes + ---------- + data_source: DataSourceConfig + Which data source should be used? + checkpoint: int + Which checkpoint of the trained model should be used? + prob_threshold: float + What is the minimum score of object/node candidates to be considered? + use_database: bool + If set, samples are read from database, not from data file + db_meta_info: DataDBMetaConfig + Identifies database to use if use_database is set + force_predict: bool + Enforce prediction, even if already done previously + """ data_source = attr.ib(converter=ensure_cls(DataSourceConfig)) checkpoint = attr.ib(type=int, default=None) prob_threshold = attr.ib(type=float) diff --git a/linajea/config/unet_config.py b/linajea/config/unet_config.py index 82662fd..b7fb9b7 100644 --- a/linajea/config/unet_config.py +++ b/linajea/config/unet_config.py @@ -1,6 +1,10 @@ -import attr +"""Configuration used to define U-Net architecture +""" + from typing import List +import attr + from .job import JobConfig from .utils import (ensure_cls, _check_nd_shape, @@ -11,7 +15,65 @@ @attr.s(kw_only=True) class UnetConfig: - path_to_script = attr.ib(type=str) + """Defines U-Net architecture + + Attributes + ---------- + path_to_script: str + Location of training script + train_input_shape, predict_input_shape: list of int + 4d (t+3d) shape (in voxels) of input to network + fmap_inc_factors: list of int + By which factor to increase number of channels during each + pooling step, number of values depends on number of pooling + steps + downsample_factors: list of list of int + By which factor to downsample during pooling, one value per + dimension per pooling step + kernel_size_down, kernel_size_up: list of list of int + Size of convolutional kernels, length of outer list depends + on number of pooling steps, length of inner list depends on + number of convolutions per step, both for encoder (down) and + decoder (up) path + upsampling: str + What kind of upsampling function should be used, one of + ["transposed_conv", + "sep_transposed_conv" (=depthwise + pixelwise), + "resize_conv", + "uniform_transposed_conv", + "pixel_shuffle", + "trilinear" (= 3d bilinear), + "nearest"] + constant_upsample: bool + Use nearest neighbor upsampling, deprecated, + overwrites upsampling + nms_window_shape, nms_window_shape_test: list of int + Size of non-max suppression window used to extract maxima of + cell indicator map, can optionally be different for + test/inference + average_vectors: bool + Compute average movement vector in 3x3x3 window around maximum + unet_style: str + Style of network used for cell indicator and vectors, one of + ["single": a single network for both, only last layer different, + "split": two completely separate networks, + "multihead": one encoder, separate decoders] + num_fmaps: int + Number of channels to create in first convolution + cell_indicator_weighted: bool + Use very small weight for pixels lower than cutoff in gt map + cell_indicator_cutoff: float + Cutoff values for weight, in gt_cell_indicator map + chkpt_parents: str + Not used, path to model checkpoint just for movement vectors + chkpt_cell_indicator + Not used, path to model checkpoint just for cell indicator + latent_temp_conv: bool + Apply temporal/4d conv not in the beginning but at bottleneck + train_only_cell_indicator: bool + Only train cell indicator network, not movement vectors + """ + path_to_script = attr.ib(type=str, default=None) # shape -> voxels, size -> world units train_input_shape = attr.ib(type=List[int], validator=[_int_list_validator, @@ -23,8 +85,10 @@ class UnetConfig: downsample_factors = attr.ib(type=List[List[int]], validator=_list_int_list_validator) kernel_size_down = attr.ib(type=List[List[int]], + default=None, validator=_check_possible_nested_lists) kernel_size_up = attr.ib(type=List[List[int]], + default=None, validator=_check_possible_nested_lists) upsampling = attr.ib(type=str, default=None, validator=attr.validators.optional(attr.validators.in_([ diff --git a/linajea/config/utils.py b/linajea/config/utils.py index 48677c6..771c112 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -1,10 +1,29 @@ -import attr -import os +"""Contains some utility functions to load configuration +""" import json +import logging +import os +import time + +import attr import toml +logger = logging.getLogger(__name__) + def load_config(config_file): + """Load toml or json config file into dict + + Args + ---- + config_file: str + path to config file, in json or toml format + + Raises + ------ + ValueError + If file not in json or toml format + """ ext = os.path.splitext(config_file)[1] with open(config_file, 'r') as f: if ext == '.json': @@ -26,23 +45,77 @@ def load_config(config_file): return config +def dump_config(config): + """Write config (as class or dict) to toml file + + Args + ---- + config: TrackingConfig or CellCycleConfig or dict + """ + if not isinstance(config, dict): + config = attr.asdict(config) + path = os.path.join(config["general"]["setup_dir"], + "tmp_configs", + "config_{}.toml".format( + time.time())) + logger.debug("config dump path: %s", path) + with open(path, 'w') as f: + toml.dump(config, f) + return path + + def ensure_cls(cl): - """If the attribute is an instance of cls, pass, else try constructing.""" + """attrs convert to ensure type of value + + If the attribute is an instance of cls or None, pass, else try + constructing. This way an instance of an attrs config object can be + passed or a dict that can be used to construct such an instance. + """ def converter(val): + if isinstance(val, str) and val.endswith(".toml"): + val = load_config(val) if isinstance(val, cl) or val is None: return val else: return cl(**val) return converter +def ensure_cls_construct_on_none(cl): + """attrs convert to ensure type of value + + If the attribute is an instance of cls, pass, else try constructing. + This way an instance of an attrs config object can be passed or a + dict that can be used to construct such an instance. + """ + def converter(val): + if isinstance(val, cl): + return val + elif val is None: + return cl() + else: + return cl(**val) + return converter + + def ensure_cls_list(cl): - """If the attribute is an list of instances of cls, pass, else try constructing.""" + """attrs converter to ensure type of values in list + + If the attribute is an list of instances of cls, pass, else try constructing. + This way a list of instances of an attrs config object can be passed + or a list of dicts that can be used to construct such instances. + + Raises + ------ + RuntimeError + If passed value is not a list + """ def converter(vals): if vals is None: return None - assert isinstance(vals, list), "list of {} expected ({})".format(cl, vals) + assert isinstance(vals, list), "list of {} expected ({})".format( + cl, vals) converted = [] for val in vals: if isinstance(val, cl) or val is None: @@ -55,12 +128,34 @@ def converter(vals): def _check_nd_shape(ndims): + """attrs validator to verify length of list + Verify that lists representing nD shapes or size have the correct + length. + """ def _check_shape(self, attribute, value): if len(value) != ndims: - raise ValueError("{} must be 4d".format(attribute)) + raise ValueError("{} must be {}d".format(attribute, ndims)) return _check_shape +def _check_nested_nd_shape(ndims): + """attrs validator to verify length of list + + Verify that lists representing nD shapes or size have the correct + length. + """ + def _check_shape(self, attribute, value): + for v in value: + if len(v) != ndims: + raise ValueError("{} must be {}d".format(attribute, ndims)) + return _check_shape + +""" +_int_list_validator: + attrs validator to validate list of ints +_list_int_list_validator: + attrs validator to validate list of list of ints +""" _int_list_validator = attr.validators.deep_iterable( member_validator=attr.validators.instance_of(int), iterable_validator=attr.validators.instance_of(list)) @@ -70,6 +165,8 @@ def _check_shape(self, attribute, value): iterable_validator=attr.validators.instance_of(list)) def _check_possible_nested_lists(self, attribute, value): + """attrs validator to verify list of ints or list of lists of ints + """ try: attr.validators.deep_iterable( member_validator=_int_list_validator, @@ -79,39 +176,71 @@ def _check_possible_nested_lists(self, attribute, value): member_validator=_list_int_list_validator, iterable_validator=attr.validators.instance_of(list))(self, attribute, value) + def maybe_fix_config_paths_to_machine_and_load(config): + """Automatically adapt paths in config to machine the code is run on + + Notes + ----- + Expects a file "linajea_paths.toml" in the home directory of the users + with two entries: + HOME: root of experiments directory + DATA: root of data directory + + Returns + ------- + dict + config with paths adapted to local machine + """ config_dict = toml.load(config) config_dict["path"] = config if os.path.isfile(os.path.join(os.environ['HOME'], "linajea_paths.toml")): paths = load_config(os.path.join(os.environ['HOME'], "linajea_paths.toml")) - # if paths["DATA"] == "TMPDIR": - # paths["DATA"] = os.environ['TMPDIR'] - config_dict["general"]["setup_dir"] = config_dict["general"]["setup_dir"].replace( - "/groups/funke/home/hirschp/linajea_experiments", - paths["HOME"]) - config_dict["model"]["path_to_script"] = config_dict["model"]["path_to_script"].replace( - "/groups/funke/home/hirschp/linajea_experiments", - paths["HOME"]) - config_dict["train"]["path_to_script"] = config_dict["train"]["path_to_script"].replace( - "/groups/funke/home/hirschp/linajea_experiments", - paths["HOME"]) - config_dict["predict"]["path_to_script"] = config_dict["predict"]["path_to_script"].replace( - "/groups/funke/home/hirschp/linajea_experiments", - paths["HOME"]) - if "path_to_script_db_from_zarr" in config_dict["predict"]: - config_dict["predict"]["path_to_script_db_from_zarr"] = config_dict["predict"]["path_to_script_db_from_zarr"].replace( - "/groups/funke/home/hirschp/linajea_experiments", - paths["HOME"]) - if "output_zarr_prefix" in config_dict["predict"]: - config_dict["predict"]["output_zarr_prefix"] = config_dict["predict"]["output_zarr_prefix"].replace( - "/nrs/funke/hirschp", - paths["DATA"]) - for dt in [config_dict["train_data"]["data_sources"], - config_dict["test_data"]["data_sources"], - config_dict["validate_data"]["data_sources"]]: - for ds in dt: - ds["datafile"]["filename"] = ds["datafile"]["filename"].replace( - "/nrs/funke/hirschp", - paths["DATA"]) + + if "general" in config_dict: + config_dict["general"]["setup_dir"] = \ + config_dict["general"]["setup_dir"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + if "model" in config_dict: + config_dict["model"]["path_to_script"] = \ + config_dict["model"]["path_to_script"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + if "train" in config_dict: + config_dict["train"]["path_to_script"] = \ + config_dict["train"]["path_to_script"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + if "predict" in config_dict: + config_dict["predict"]["path_to_script"] = \ + config_dict["predict"]["path_to_script"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + if "path_to_script_db_from_zarr" in config_dict["predict"]: + config_dict["predict"]["path_to_script_db_from_zarr"] = \ + config_dict["predict"]["path_to_script_db_from_zarr"].replace( + "/groups/funke/home/hirschp/linajea_experiments", + paths["HOME"]) + if "output_zarr_prefix" in config_dict["predict"]: + config_dict["predict"]["output_zarr_prefix"] = \ + config_dict["predict"]["output_zarr_prefix"].replace( + "/nrs/funke/hirschp", + paths["DATA"]) + dss = [] + dss += ["train_data"] if "train_data" in config_dict else [] + dss += ["test_data"] if "test_data" in config_dict else [] + dss += ["validate_data"] if "validate_data" in config_dict else [] + dss += ["inference_data"] if "inference_data" in config_dict else [] + for ds in dss: + ds = (config_dict[ds]["data_sources"] + if "data_sources" in config_dict[ds] + else [config_dict[ds]["data_source"]]) + for sample in ds: + if "datafile" in sample: + sample["datafile"]["filename"] = \ + sample["datafile"]["filename"].replace( + "/nrs/funke/hirschp", + paths["DATA"]) return config_dict diff --git a/linajea/evaluation/analyze_results.py b/linajea/evaluation/analyze_results.py index f43c425..83636e3 100644 --- a/linajea/evaluation/analyze_results.py +++ b/linajea/evaluation/analyze_results.py @@ -4,8 +4,8 @@ import pandas -from linajea import (CandidateDatabase, - checkOrCreateDB) +from linajea.utils import (CandidateDatabase, + checkOrCreateDB) from linajea.tracking import TrackingParameters logger = logging.getLogger(__name__) @@ -181,7 +181,7 @@ def get_results_sorted(config, if not score_weights: score_weights = [1.]*len(score_columns) - db_name = config.inference.data_source.db_name + db_name = config.inference_data.data_source.db_name logger.info("checking db: %s", db_name) @@ -282,7 +282,7 @@ def get_result_id( Returns a dictionary containing the keys and values of the score object. ''' - db_name = config.inference.data_source.db_name + db_name = config.inference_data.data_source.db_name candidate_db = CandidateDatabase(db_name, config.general.db_host, 'r') result = candidate_db.get_score(parameters_id, @@ -298,10 +298,10 @@ def get_result_params( Returns a dictionary containing the keys and values of the score object. ''' - db_name = config.inference.data_source.db_name + db_name = config.inference_data.data_source.db_name candidate_db = CandidateDatabase(db_name, config.general.db_host, 'r') if config.evaluate.parameters.roi is None: - config.evaluate.parameters.roi = config.inference.data_source.roi + config.evaluate.parameters.roi = config.inference_data.data_source.roi result = candidate_db.get_score( candidate_db.get_parameters_id_round( diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index ddf244b..bf90e65 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -11,6 +11,7 @@ import funlib.math import linajea.tracking +import linajea.utils from .evaluate import evaluate logger = logging.getLogger(__name__) @@ -22,9 +23,11 @@ def evaluate_setup(linajea_config): "can only handle single parameter set" parameters = linajea_config.solve.parameters[0] - data = linajea_config.inference.data_source + data = linajea_config.inference_data.data_source db_name = data.db_name db_host = linajea_config.general.db_host + logger.debug("ROI used for evaluation: %s %s", + linajea_config.evaluate.parameters.roi, data.roi) if linajea_config.evaluate.parameters.roi is not None: assert linajea_config.evaluate.parameters.roi.shape[0] <= data.roi.shape[0], \ "your evaluation ROI is larger than your data roi!" @@ -52,10 +55,11 @@ def evaluate_setup(linajea_config): return logger.info("Evaluating %s in %s", - os.path.basename(data.datafile.filename), evaluate_roi) + os.path.basename(data.datafile.filename) + if data.datafile is not None else db_name, evaluate_roi) - edges_db = linajea.CandidateDatabase(db_name, db_host, - parameters_id=parameters_id) + edges_db = linajea.utils.CandidateDatabase(db_name, db_host, + parameters_id=parameters_id) logger.info("Reading cells and edges in db %s with parameter_id %d" % (db_name, parameters_id)) @@ -86,11 +90,11 @@ def evaluate_setup(linajea_config): track_graph = linajea.tracking.TrackGraph( subgraph, frame_key='t', roi=subgraph.roi) - gt_db = linajea.CandidateDatabase( - linajea_config.inference.data_source.gt_db_name, db_host) + gt_db = linajea.utils.CandidateDatabase( + linajea_config.inference_data.data_source.gt_db_name, db_host) logger.info("Reading ground truth cells and edges in db %s" - % linajea_config.inference.data_source.gt_db_name) + % linajea_config.inference_data.data_source.gt_db_name) start_time = time.time() gt_subgraph = gt_db.get_graph( evaluate_roi, @@ -101,7 +105,7 @@ def evaluate_setup(linajea_config): gt_subgraph.number_of_edges(), time.time() - start_time)) - if linajea_config.inference.data_source.gt_db_name_polar is not None and \ + if linajea_config.inference_data.data_source.gt_db_name_polar is not None and \ not linajea_config.evaluate.parameters.filter_polar_bodies and \ not linajea_config.evaluate.parameters.filter_polar_bodies_key: gt_subgraph = add_gt_polar_bodies(linajea_config, gt_subgraph, @@ -154,7 +158,7 @@ def evaluate_setup(linajea_config): def split_two_frame_edges(linajea_config, subgraph, evaluate_roi): - voxel_size = daisy.Coordinate(linajea_config.inference.data_source.voxel_size) + voxel_size = daisy.Coordinate(linajea_config.inference_data.data_source.voxel_size) cells_by_frame = {} for cell in subgraph.nodes: cell = subgraph.nodes[cell] @@ -291,7 +295,7 @@ def maybe_filter_short_tracklets(linajea_config, subgraph, evaluate_roi): logger.info("track begin: {}, track end: {}, track len: {}".format( min_t, max_t, len(track.nodes()))) - if len(track.nodes()) < linajea_config.evaluate.filter_short_tracklets_len \ + if len(track.nodes()) < linajea_config.evaluate.parameters.filter_short_tracklets_len \ and max_t != last_frame: logger.info("removing %s nodes (very short tracks < %d)", len(track.nodes()), @@ -303,8 +307,8 @@ def maybe_filter_short_tracklets(linajea_config, subgraph, evaluate_roi): def add_gt_polar_bodies(linajea_config, gt_subgraph, db_host, evaluate_roi): logger.info("polar bodies are not filtered, adding polar body GT..") - gt_db_polar = linajea.CandidateDatabase( - linajea_config.inference.data_source.gt_db_name_polar, db_host) + gt_db_polar = linajea.utils.CandidateDatabase( + linajea_config.inference_data.data_source.gt_db_name_polar, db_host) gt_polar_subgraph = gt_db_polar[evaluate_roi] gt_mx_id = max(gt_subgraph.nodes()) + 1 mapping = {n: n+gt_mx_id for n in gt_polar_subgraph.nodes()} diff --git a/linajea/load_config.py b/linajea/load_config.py deleted file mode 100644 index cf087d5..0000000 --- a/linajea/load_config.py +++ /dev/null @@ -1,46 +0,0 @@ -import json -import toml -import os.path -import logging -from linajea.tracking import TrackingParameters, NMTrackingParameters - -logger = logging.getLogger(__name__) - - -def load_config(config_file): - ext = os.path.splitext(config_file)[1] - with open(config_file, 'r') as f: - if ext == '.json': - config = json.load(f) - elif ext == '.toml': - config = toml.load(f) - elif ext == '': - try: - config = toml.load(f) - except ValueError: - try: - config = json.load(f) - except ValueError: - raise ValueError("No file extension provided " - "and cannot be loaded with json or toml") - else: - raise ValueError("Only json and toml config files supported," - " not %s" % ext) - return config - - -def tracking_params_from_config(config): - solve_config = {} - solve_config.update(config['general']) - solve_config.update(config['solve']) - if 'version' not in solve_config: - version = solve_config['singularity_image'].split(':')[-1] - solve_config['version'] = version - logger.debug("Version: %s" % solve_config['version']) - solve_config.update({ - 'max_cell_move': config['extract_edges']['edge_move_threshold']}) - - if 'cost_appear' in solve_config: - return NMTrackingParameters(**solve_config) - else: - return TrackingParameters(**solve_config) diff --git a/linajea/process_blockwise/extract_edges_blockwise.py b/linajea/process_blockwise/extract_edges_blockwise.py index 218135e..a8314fa 100644 --- a/linajea/process_blockwise/extract_edges_blockwise.py +++ b/linajea/process_blockwise/extract_edges_blockwise.py @@ -12,29 +12,20 @@ logger = logging.getLogger(__name__) -def extract_edges_blockwise( - db_host, - db_name, - sample, - edge_move_threshold, - block_size, - num_workers, - frames=None, - frame_context=1, - data_dir='../01_data', - **kwargs): - - voxel_size, source_roi = get_source_roi(data_dir, sample) - - # limit to specific frames, if given - if frames: - begin, end = frames - begin -= frame_context - end += frame_context - crop_roi = daisy.Roi( - (begin, None, None, None), - (end - begin, None, None, None)) - source_roi = source_roi.intersect(crop_roi) +def extract_edges_blockwise(linajea_config): + + data = linajea_config.inference_data.data_source + extract_roi = daisy.Roi(offset=data.roi.offset, + shape=data.roi.shape) + # allow for solve context + extract_roi = extract_roi.grow( + daisy.Coordinate(linajea_config.solve.parameters[0].context), + daisy.Coordinate(linajea_config.solve.parameters[0].context)) + # but limit to actual file roi + if data.datafile is not None: + extract_roi = extract_roi.intersect( + daisy.Roi(offset=data.datafile.file_roi.offset, + shape=data.datafile.file_roi.shape)) # block size in world units block_write_roi = daisy.Roi( @@ -56,7 +47,8 @@ def extract_edges_blockwise( logger.info("Output ROI = %s", extract_roi) logger.info("Starting block-wise processing...") - logger.info("Sample: %s", data.datafile.filename) + if data.datafile is not None: + logger.info("Sample: %s", data.datafile.filename) logger.info("DB: %s", data.db_name) # process block-wise @@ -90,11 +82,11 @@ def extract_edges_in_block( "Finding edges in %s, reading from %s", block.write_roi, block.read_roi) - data = linajea_config.inference.data_source + data = linajea_config.inference_data.data_source start = time.time() - graph_provider = linajea.CandidateDatabase( + graph_provider = linajea.utils.CandidateDatabase( data.db_name, linajea_config.general.db_host, mode='r+') diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index fcbffc7..e1acc3c 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -11,55 +11,29 @@ from funlib.run import run from .daisy_check_functions import check_function -from ..construct_zarr_filename import construct_zarr_filename -from ..datasets import get_source_roi +from linajea.utils import construct_zarr_filename logger = logging.getLogger(__name__) -def predict_blockwise( - config_file, - iteration - ): - config = { - "solve_context": daisy.Coordinate((2, 100, 100, 100)), - "num_workers": 16, - "data_dir": '../01_data', - "setups_dir": '../02_setups', - } - master_config = load_config(config_file) - config.update(master_config['general']) - config.update(master_config['predict']) - sample = config['sample'] - data_dir = config['data_dir'] - setup = config['setup'] - # solve_context = daisy.Coordinate(master_config['solve']['context']) - setup_dir = os.path.abspath( - os.path.join(config['setups_dir'], setup)) - voxel_size, source_roi = get_source_roi(data_dir, sample) - predict_roi = source_roi - - # limit to specific frames, if given - if 'limit_to_roi_offset' in config or 'frames' in config: - if 'frames' in config: - frames = config['frames'] - logger.info("Limiting prediction to frames %s" % str(frames)) - begin, end = frames - frames_roi = daisy.Roi( - (begin, None, None, None), - (end - begin, None, None, None)) - predict_roi = predict_roi.intersect(frames_roi) - if 'limit_to_roi_offset' in config: - assert 'limit_to_roi_shape' in config,\ - "Must specify shape and offset in config file" - limit_to_roi = daisy.Roi( - daisy.Coordinate(config['limit_to_roi_offset']), - daisy.Coordinate(config['limit_to_roi_shape'])) - predict_roi = predict_roi.intersect(limit_to_roi) - # Given frames and rois are the prediction region, - # not the solution region - # predict_roi = target_roi.grow(solve_context, solve_context) - # predict_roi = predict_roi.intersect(source_roi) +def predict_blockwise(linajea_config): + setup_dir = linajea_config.general.setup_dir + + data = linajea_config.inference_data.data_source + assert data.db_name is not None, "db_name must be set" + assert data.voxel_size is not None, "voxel_size must be set" + voxel_size = daisy.Coordinate(data.voxel_size) + predict_roi = daisy.Roi(offset=data.roi.offset, + shape=data.roi.shape) + # allow for solve context + predict_roi = predict_roi.grow( + daisy.Coordinate(linajea_config.solve.parameters[0].context), + daisy.Coordinate(linajea_config.solve.parameters[0].context)) + # but limit to actual file roi + if data.datafile is not None: + predict_roi = predict_roi.intersect( + daisy.Roi(offset=data.datafile.file_roi.offset, + shape=data.datafile.file_roi.shape)) # get context and total input and output ROI with open(os.path.join(setup_dir, 'test_net_config.json'), 'r') as f: @@ -82,7 +56,7 @@ def predict_blockwise( output_zarr = construct_zarr_filename(linajea_config, data.datafile.filename, - linajea_config.inference.checkpoint) + linajea_config.inference_data.checkpoint) if linajea_config.predict.write_db_from_zarr: assert os.path.exists(output_zarr), \ @@ -174,18 +148,21 @@ def predict_worker(linajea_config): worker_time = time.time() job = linajea_config.predict.job + script_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "prediction") if linajea_config.predict.write_db_from_zarr: - path_to_script = linajea_config.predict.path_to_script_db_from_zarr + script = os.path.join(script_dir, "write_cells_from_zarr.py") else: - path_to_script = linajea_config.predict.path_to_script + script = os.path.join(script_dir, "predict.py") command = 'python -u %s --config %s' % ( - path_to_script, + script, linajea_config.path) - if job.local: + if job.run_on == "local": cmd = [command] - elif os.path.isdir("/nrs/funke"): + elif job.run_on == "lsf": cmd = run( command=command.split(" "), queue=job.queue, diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index a7a3864..dd08974 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -17,7 +17,7 @@ def solve_blockwise(linajea_config): block_size = daisy.Coordinate(parameters[0].block_size) context = daisy.Coordinate(parameters[0].context) - data = linajea_config.inference.data_source + data = linajea_config.inference_data.data_source db_name = data.db_name db_host = linajea_config.general.db_host solve_roi = daisy.Roi(offset=data.roi.offset, @@ -49,7 +49,8 @@ def solve_blockwise(linajea_config): context) logger.info("Solving in %s", total_roi) - logger.info("Sample: %s", data.datafile.filename) + if data.datafile is not None: + logger.info("Sample: %s", data.datafile.filename) logger.info("DB: %s", db_name) param_names = ['solve_' + str(_id) for _id in parameters_id] @@ -123,7 +124,7 @@ def solve_in_block(linajea_config, # data from outside the solution roi # or paying the appear or disappear costs unnecessarily - db_name = linajea_config.inference.data_source.db_name + db_name = linajea_config.inference_data.data_source.db_name db_host = linajea_config.general.db_host if len(parameters_id) == 1: diff --git a/linajea/tracking/greedy_track.py b/linajea/tracking/greedy_track.py index e23e50b..7ce10fe 100644 --- a/linajea/tracking/greedy_track.py +++ b/linajea/tracking/greedy_track.py @@ -1,6 +1,6 @@ import logging import networkx as nx -from linajea import CandidateDatabase +from linajea.utils import CandidateDatabase from daisy import Roi from .track_graph import TrackGraph From ef6af3fc24f62e528d77ba97152df6c495d5635d Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 09:58:30 -0400 Subject: [PATCH 189/263] add predict script from experiments repo --- linajea/prediction/__init__.py | 3 + linajea/prediction/predict.py | 251 ++++++++++++++++++++ linajea/prediction/write_cells_from_zarr.py | 148 ++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 linajea/prediction/__init__.py create mode 100644 linajea/prediction/predict.py create mode 100644 linajea/prediction/write_cells_from_zarr.py diff --git a/linajea/prediction/__init__.py b/linajea/prediction/__init__.py new file mode 100644 index 0000000..5e1a9c6 --- /dev/null +++ b/linajea/prediction/__init__.py @@ -0,0 +1,3 @@ +# flake8: noqa +from .predict import predict +from .write_cells_from_zarr import write_cells_from_zarr diff --git a/linajea/prediction/predict.py b/linajea/prediction/predict.py new file mode 100644 index 0000000..473593e --- /dev/null +++ b/linajea/prediction/predict.py @@ -0,0 +1,251 @@ +import warnings +warnings.filterwarnings("once", category=FutureWarning) + +import argparse +import logging +import os + +import h5py +import numpy as np +import torch + +import daisy +import gunpowder as gp + +from linajea.config import (load_config, + TrackingConfig) +from linajea.gunpowder_nodes import (Clip, + NormalizeMinMax, + NormalizeMeanStd, + NormalizeMedianMad, + WriteCells) +from linajea.process_blockwise import write_done +import linajea.training.torch_model +from linajea.utils import construct_zarr_filename + + +logger = logging.getLogger(__name__) + + +def predict(config): + + raw = gp.ArrayKey('RAW') + cell_indicator = gp.ArrayKey('CELL_INDICATOR') + maxima = gp.ArrayKey('MAXIMA') + if not config.model.train_only_cell_indicator: + parent_vectors = gp.ArrayKey('PARENT_VECTORS') + + model = linajea.training.torch_model.UnetModelWrapper( + config, config.inference_data.checkpoint) + model.eval() + logger.info("Model: %s", model) + + input_shape = config.model.predict_input_shape + trial_run = model.forward(torch.zeros(input_shape, dtype=torch.float32)) + _, _, trial_max, _ = trial_run + output_shape = trial_max.size() + + voxel_size = gp.Coordinate(config.inference_data.data_source.voxel_size) + input_size = gp.Coordinate(input_shape) * voxel_size + output_size = gp.Coordinate(output_shape) * voxel_size + + chunk_request = gp.BatchRequest() + chunk_request.add(raw, input_size) + chunk_request.add(cell_indicator, output_size) + chunk_request.add(maxima, output_size) + if not config.model.train_only_cell_indicator: + chunk_request.add(parent_vectors, output_size) + + sample = config.inference_data.data_source.datafile.filename + if os.path.isfile(os.path.join(sample, "data_config.toml")): + data_config = load_config( + os.path.join(sample, "data_config.toml")) + try: + filename_data = os.path.join( + sample, data_config['general']['data_file']) + except KeyError: + filename_data = os.path.join( + sample, data_config['general']['zarr_file']) + filename_mask = os.path.join( + sample, + data_config['general'].get('mask_file', os.path.splitext( + data_config['general']['zarr_file'])[0] + "_mask.hdf")) + z_range = data_config['general']['z_range'] + if z_range[1] < 0: + z_range[1] = data_config['general']['shape'][1] - z_range[1] + volume_shape = data_config['general']['shape'] + else: + data_config = None + filename_data = sample + filename_mask = sample + "_mask.hdf" + z_range = None + volume_shape = daisy.open_ds( + filename_data, + config.inference_data.data_source.datafile.group).roi.get_shape() + + if os.path.isfile(filename_mask): + with h5py.File(filename_mask, 'r') as f: + mask = np.array(f['volumes/mask']) + else: + mask = None + + source = gp.ZarrSource( + filename_data, + datasets={ + raw: config.inference_data.data_source.datafile.group + }, + nested="nested" in config.inference_data.data_source.datafile.group, + array_specs={ + raw: gp.ArraySpec( + interpolatable=True, + voxel_size=voxel_size)}) + + source = normalize(source, config, raw, data_config) + + inputs={ + 'raw': raw + } + outputs={ + 0: cell_indicator, + 1: maxima, + } + if not config.model.train_only_cell_indicator: + outputs[3] = parent_vectors + + dataset_names={ + cell_indicator: 'volumes/cell_indicator', + } + if not config.model.train_only_cell_indicator: + dataset_names[parent_vectors] = 'volumes/parent_vectors' + + pipeline = ( + source + + gp.Pad(raw, size=None) + + gp.torch.Predict( + model=model, + checkpoint=os.path.join(config.general.setup_dir, + 'train_net_checkpoint_{}'.format( + config.inference_data.checkpoint)), + inputs=inputs, + outputs=outputs, + use_swa=config.predict.use_swa + )) + + cb = [] + if config.predict.write_to_zarr: + pipeline = ( + pipeline + + + gp.ZarrWrite( + dataset_names=dataset_names, + output_filename=construct_zarr_filename(config, sample, + config.inference_data.checkpoint) + )) + if not config.predict.no_db_access: + cb.append(lambda b: write_done( + b, + 'predict_zarr', + config.inference_data.data_source.db_name, + config.general.db_host)) + else: + cb.append(lambda _: True) + + if config.predict.write_to_db: + pipeline = ( + pipeline + + + WriteCells( + maxima, + cell_indicator, + parent_vectors if not config.model.train_only_cell_indicator else None, + score_threshold=config.inference_data.cell_score_threshold, + db_host=config.general.db_host, + db_name=config.inference_data.data_source.db_name, + mask=mask, + z_range=z_range, + volume_shape=volume_shape) + ) + cb.append(lambda b: write_done( + b, + 'predict_db', + db_name=config.inference_data.data_source.db_name, + db_host=config.general.db_host)) + + + roi_map = { + raw: 'read_roi', + cell_indicator: 'write_roi', + maxima: 'write_roi' + } + if not config.model.train_only_cell_indicator: + roi_map[parent_vectors] = 'write_roi' + + pipeline = ( + pipeline + + + gp.PrintProfilingStats(every=100) + + gp.DaisyRequestBlocks( + chunk_request, + roi_map=roi_map, + num_workers=1, + block_done_callback=lambda b, st, et: all([f(b) for f in cb]) + )) + + with gp.build(pipeline): + pipeline.request_batch(gp.BatchRequest()) + + +def normalize(file_source, config, raw, data_config=None): + if config.predict.normalization is None or \ + config.predict.normalization.type == 'default': + logger.info("default normalization") + file_source = file_source + \ + gp.Normalize(raw, + factor=1.0/np.iinfo(data_config['stats']['dtype']).max) + elif config.predict.normalization.type == 'minmax': + mn = config.predict.normalization.norm_bounds[0] + mx = config.predict.normalization.norm_bounds[1] + logger.info("minmax normalization %s %s", mn, mx) + file_source = file_source + \ + Clip(raw, mn=mn/2, mx=mx*2) + \ + NormalizeMinMax(raw, mn=mn, mx=mx, interpolatable=False) + elif config.predict.normalization.type == 'percminmax': + mn = data_config['stats'][config.predict.normalization.perc_min] + mx = data_config['stats'][config.predict.normalization.perc_max] + logger.info("perc minmax normalization %s %s", mn, mx) + file_source = file_source + \ + Clip(raw, mn=mn/2, mx=mx*2) + \ + NormalizeMinMax(raw, mn=mn, mx=mx) + elif config.predict.normalization.type == 'mean': + mean = data_config['stats']['mean'] + std = data_config['stats']['std'] + mn = data_config['stats'][config.predict.normalization.perc_min] + mx = data_config['stats'][config.predict.normalization.perc_max] + logger.info("mean normalization %s %s %s %s", mean, std, mn, mx) + file_source = file_source + \ + Clip(raw, mn=mn, mx=mx) + \ + NormalizeMeanStd(raw, mean=mean, std=std) + elif config.predict.normalization.type == 'median': + median = data_config['stats']['median'] + mad = data_config['stats']['mad'] + mn = data_config['stats'][config.predict.normalization.perc_min] + mx = data_config['stats'][config.predict.normalization.perc_max] + logger.info("median normalization %s %s %s %s", median, mad, mn, mx) + file_source = file_source + \ + Clip(raw, mn=mn, mx=mx) + \ + NormalizeMedianMad(raw, median=median, mad=mad) + else: + raise RuntimeError("invalid normalization method %s", + config.predict.normalization.type) + return file_source + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser() + parser.add_argument('--config', type=str, + help='path to config file') + args = parser.parse_args() + + config = TrackingConfig.from_file(args.config) + predict(config) diff --git a/linajea/prediction/write_cells_from_zarr.py b/linajea/prediction/write_cells_from_zarr.py new file mode 100644 index 0000000..586456e --- /dev/null +++ b/linajea/prediction/write_cells_from_zarr.py @@ -0,0 +1,148 @@ +import warnings +warnings.filterwarnings("once", category=FutureWarning) + +import argparse +import logging +import os + + +import h5py +import numpy as np + +import gunpowder as gp + +from linajea.config import TrackingConfig +from linajea.gunpowder_nodes import WriteCells +from linajea.process_blockwise import write_done +from linajea.utils import (load_config, + construct_zarr_filename) + + +logger = logging.getLogger(__name__) + + +def write_cells_from_zarr(config): + + cell_indicator = gp.ArrayKey('CELL_INDICATOR') + maxima = gp.ArrayKey('MAXIMA') + if not config.model.train_only_cell_indicator: + parent_vectors = gp.ArrayKey('PARENT_VECTORS') + + voxel_size = gp.Coordinate(config.inference_data.data_source.voxel_size) + output_size = gp.Coordinate(output_shape) * voxel_size + + chunk_request = gp.BatchRequest() + chunk_request.add(cell_indicator, output_size) + chunk_request.add(maxima, output_size) + if not config.model.train_only_cell_indicator: + chunk_request.add(parent_vectors, output_size) + + sample = config.inference_data.data_source.datafile.filename + if os.path.isfile(os.path.join(sample, "data_config.toml")): + data_config = load_config( + os.path.join(sample, "data_config.toml")) + filename_mask = os.path.join( + sample, + data_config['general'].get('mask_file', os.path.splitext( + data_config['general']['zarr_file'])[0] + "_mask.hdf")) + z_range = data_config['general']['z_range'] + if z_range[1] < 0: + z_range[1] = data_config['general']['shape'][1] - z_range[1] + volume_shape = data_config['general']['shape'] + else: + data_config = None + filename_data = sample + filename_mask = sample + "_mask.hdf" + z_range = None + volume_shape = daisy.open_ds( + filename_data, + config.inference_data.data_source.datafile.group).roi.get_shape() + + if os.path.isfile(filename_mask): + with h5py.File(filename_mask, 'r') as f: + mask = np.array(f['volumes/mask']) + else: + mask = None + + output_path = construct_zarr_filename( + config, + config.inference_data.data_source.datafile.filename, + config.inference_data.checkpoint) + + datasets = { + cell_indicator: 'volumes/cell_indicator', + maxima: '/volumes/maxima'} + if not config.model.train_only_cell_indicator: + datasets[parent_vectors] = 'volumes/parent_vectors' + + array_specs = { + cell_indicator: gp.ArraySpec( + interpolatable=True, + voxel_size=voxel_size), + maxima: gp.ArraySpec( + interpolatable=False, + voxel_size=voxel_size)} + if not config.model.train_only_cell_indicator: + array_specs[parent_vectors] = gp.ArraySpec( + interpolatable=True, + voxel_size=voxel_size) + + source = gp.ZarrSource( + output_path, + datasets=datasets, + array_specs=array_specs) + + roi_map = { + cell_indicator: 'write_roi', + maxima: 'write_roi' + } + if not config.model.train_only_cell_indicator: + roi_map[parent_vectors] = 'write_roi' + + pipeline = ( + source + + gp.Pad(cell_indicator, size=None) + + gp.Pad(maxima, size=None)) + + if not config.model.train_only_cell_indicator: + pipeline = (pipeline + + gp.Pad(parent_vectors, size=None)) + + pipeline = ( + pipeline + + WriteCells( + maxima, + cell_indicator, + parent_vectors if not config.model.train_only_cell_indicator else None, + score_threshold=config.inference_data.cell_score_threshold, + db_host=config.general.db_host, + db_name=config.inference_data.data_source.db_name, + mask=mask, + z_range=z_range, + volume_shape=volume_shape) + + gp.PrintProfilingStats(every=100) + + gp.DaisyRequestBlocks( + chunk_request, + roi_map=roi_map, + num_workers=1, + block_done_callback=lambda b, st, et: write_done( + b, + 'predict_db', + config.inference_data.data_source.db_name, + config.general.db_host) + )) + + with gp.build(pipeline): + pipeline.request_batch(gp.BatchRequest()) + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser() + parser.add_argument('--config', type=str, + help='path to config file') + + args = parser.parse_args() + + config = TrackingConfig.from_file(args.config) + write_cells_sample(config) From 0f5642605acd8459b49731b5dd180094bc5da9e4 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 10:02:02 -0400 Subject: [PATCH 190/263] updates for new daisy version, some logging and other cleanup --- linajea/evaluation/evaluate_setup.py | 46 +++++++------------ linajea/evaluation/match.py | 33 ++++++------- linajea/evaluation/report.py | 1 + .../daisy_check_functions.py | 5 +- .../extract_edges_blockwise.py | 6 ++- .../process_blockwise/predict_blockwise.py | 24 ++++------ linajea/process_blockwise/solve_blockwise.py | 18 +++++--- 7 files changed, 61 insertions(+), 72 deletions(-) diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index bf90e65..7d19d1a 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -13,6 +13,7 @@ import linajea.tracking import linajea.utils from .evaluate import evaluate +from .report import Report logger = logging.getLogger(__name__) @@ -38,8 +39,9 @@ def evaluate_setup(linajea_config): shape=data.roi.shape) # determine parameters id from database - results_db = linajea.CandidateDatabase(db_name, db_host) - parameters_id = results_db.get_parameters_id(parameters) + results_db = linajea.utils.CandidateDatabase(db_name, db_host) + parameters_id = results_db.get_parameters_id(parameters, + fail_if_not_exists=True) if not linajea_config.evaluate.from_scratch: old_score = results_db.get_score(parameters_id, @@ -51,8 +53,10 @@ def evaluate_setup(linajea_config): for k, v in old_score.items(): if not isinstance(k, list) or k != "roi": score[k] = v - logger.info("Stored results: %s", score) - return + logger.debug("Stored results: %s", score) + report = Report() + report.__dict__.update(score) + return report logger.info("Evaluating %s in %s", os.path.basename(data.datafile.filename) @@ -77,7 +81,7 @@ def evaluate_setup(linajea_config): if subgraph.number_of_edges() == 0: logger.warn("No selected edges for parameters_id %d. Skipping" % parameters_id) - return + return False if linajea_config.evaluate.parameters.filter_polar_bodies or \ linajea_config.evaluate.parameters.filter_polar_bodies_key: @@ -98,8 +102,7 @@ def evaluate_setup(linajea_config): start_time = time.time() gt_subgraph = gt_db.get_graph( evaluate_roi, - subsampling=linajea_config.general.subsampling, - subsampling_seed=linajea_config.general.subsampling_seed) + ) logger.info("Read %d cells and %d edges in %s seconds" % (gt_subgraph.number_of_nodes(), gt_subgraph.number_of_edges(), @@ -122,6 +125,7 @@ def evaluate_setup(linajea_config): fn_div_count_unconnected_parent = \ linajea_config.evaluate.parameters.fn_div_count_unconnected_parent window_size=linajea_config.evaluate.parameters.window_size + report = evaluate( gt_track_graph, track_graph, @@ -134,27 +138,10 @@ def evaluate_setup(linajea_config): logger.info("Done evaluating results for %d. Saving results to mongo." % parameters_id) - logger.info("Result summary: %s", report.get_short_report()) + logger.debug("Result summary: %s", report.get_short_report()) results_db.write_score(parameters_id, report, eval_params=linajea_config.evaluate.parameters) - res = report.get_short_report() - print("| | fp | fn | id | fp_div | fn_div | sum_div |" - " sum | DET | TRA | REFT | NR | ER | GT |") - sum_errors = (res['fp_edges'] + res['fn_edges'] + - res['identity_switches'] + - res['fp_divisions'] + res['fn_divisions']) - sum_divs = res['fp_divisions'] + res['fn_divisions'] - reft = res["num_error_free_tracks"]/res["num_gt_cells_last_frame"] - print("| {:3d} | {:3d} | {:3d} |" - " {:3d} | {:3d} | {:3d} | {:3d} | | |" - " {:.4f} | {:.4f} | {:.4f} | {:5d} |".format( - int(res['fp_edges']), int(res['fn_edges']), - int(res['identity_switches']), - int(res['fp_divisions']), int(res['fn_divisions']), - int(sum_divs), - int(sum_errors), - reft, res['node_recall'], res['edge_recall'], - int(res['gt_edges']))) + return report def split_two_frame_edges(linajea_config, subgraph, evaluate_roi): @@ -292,7 +279,7 @@ def maybe_filter_short_tracklets(linajea_config, subgraph, evaluate_roi): if node['t'] > max_t: max_t = node['t'] - logger.info("track begin: {}, track end: {}, track len: {}".format( + logger.debug("track begin: {}, track end: {}, track len: {}".format( min_t, max_t, len(track.nodes()))) if len(track.nodes()) < linajea_config.evaluate.parameters.filter_short_tracklets_len \ @@ -316,9 +303,8 @@ def add_gt_polar_bodies(linajea_config, gt_subgraph, db_host, evaluate_roi): copy=False) gt_subgraph.update(gt_polar_subgraph) - logger.info("Read %d cells and %d edges in %s seconds (after adding polar bodies)" + logger.info("Read %d cells and %d edges (after adding polar bodies)" % (gt_subgraph.number_of_nodes(), - gt_subgraph.number_of_edges(), - time.time() - start_time)) + gt_subgraph.number_of_edges())) return gt_subgraph diff --git a/linajea/evaluation/match.py b/linajea/evaluation/match.py index 83e2f2f..783bfac 100644 --- a/linajea/evaluation/match.py +++ b/linajea/evaluation/match.py @@ -182,22 +182,23 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): np.min(avg_dist_frame), np.max(avg_dist_frame)) - logger.debug("total count matches %d", len(avg_dist_source)) - logger.debug("dist source: avg %.3f, med %.3f, min %.3f, max %.3f", - np.mean(avg_dist_source), - np.median(avg_dist_source), - np.min(avg_dist_source), - np.max(avg_dist_source)) - logger.debug("dist target: avg %.3f, med %.3f, min %.3f, max %.3f", - np.mean(avg_dist_target), - np.median(avg_dist_target), - np.min(avg_dist_target), - np.max(avg_dist_target)) - logger.debug("dist : avg %.3f, med %.3f, min %.3f, max %.3f", - np.mean(avg_dist), - np.median(avg_dist), - np.min(avg_dist), - np.max(avg_dist)) + if len(avg_dist_source) > 0: + logger.debug("total count matches %d", len(avg_dist_source)) + logger.debug("dist source: avg %.3f, med %.3f, min %.3f, max %.3f", + np.mean(avg_dist_source), + np.median(avg_dist_source), + np.min(avg_dist_source), + np.max(avg_dist_source)) + logger.debug("dist target: avg %.3f, med %.3f, min %.3f, max %.3f", + np.mean(avg_dist_target), + np.median(avg_dist_target), + np.min(avg_dist_target), + np.max(avg_dist_target)) + logger.debug("dist : avg %.3f, med %.3f, min %.3f, max %.3f", + np.mean(avg_dist), + np.median(avg_dist), + np.min(avg_dist), + np.max(avg_dist)) logger.info("Done matching, found %d matches and %d edge fps" % (len(edge_matches), edge_fps)) return edges_x, edges_y, edge_matches, edge_fps diff --git a/linajea/evaluation/report.py b/linajea/evaluation/report.py index 1e286cf..5dbdc32 100644 --- a/linajea/evaluation/report.py +++ b/linajea/evaluation/report.py @@ -291,5 +291,6 @@ def get_short_report(self): del report['unconnected_child_gt_nodes'] del report['unconnected_parent_gt_nodes'] del report['tp_div_gt_nodes'] + del report['correct_segments'] return report diff --git a/linajea/process_blockwise/daisy_check_functions.py b/linajea/process_blockwise/daisy_check_functions.py index 397308f..9ce5c46 100644 --- a/linajea/process_blockwise/daisy_check_functions.py +++ b/linajea/process_blockwise/daisy_check_functions.py @@ -1,6 +1,5 @@ import pymongo - def get_daisy_collection_name(step_name): return step_name + "_daisy" @@ -9,7 +8,7 @@ def check_function(block, step_name, db_name, db_host): client = pymongo.MongoClient(db_host) db = client[db_name] daisy_coll = db[get_daisy_collection_name(step_name)] - result = daisy_coll.find_one({'_id': block.block_id}) + result = daisy_coll.find_one({'_id': block.block_id[1]}) if result is None: return False else: @@ -20,7 +19,7 @@ def write_done(block, step_name, db_name, db_host): client = pymongo.MongoClient(db_host) db = client[db_name] daisy_coll = db[get_daisy_collection_name(step_name)] - daisy_coll.insert_one({'_id': block.block_id}) + daisy_coll.insert_one({'_id': block.block_id[1]}) def check_function_all_blocks(step_name, db_name, db_host): diff --git a/linajea/process_blockwise/extract_edges_blockwise.py b/linajea/process_blockwise/extract_edges_blockwise.py index a8314fa..f689796 100644 --- a/linajea/process_blockwise/extract_edges_blockwise.py +++ b/linajea/process_blockwise/extract_edges_blockwise.py @@ -52,7 +52,8 @@ def extract_edges_blockwise(linajea_config): logger.info("DB: %s", data.db_name) # process block-wise - daisy.run_blockwise( + task = daisy.Task( + "linajea_extract_edges", input_roi, block_read_roi, block_write_roi, @@ -67,10 +68,11 @@ def extract_edges_blockwise(linajea_config): data.db_name, linajea_config.general.db_host), num_workers=linajea_config.extract.job.num_workers, - processes=True, read_write_conflict=False, fit='overhang') + daisy.run_blockwise([task]) + def extract_edges_in_block( db_name, diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index e1acc3c..bc3eceb 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -2,6 +2,7 @@ import json import logging import os +import subprocess import time import numpy as np @@ -130,7 +131,8 @@ def predict_blockwise(linajea_config): linajea_config.general.db_host)) # process block-wise - daisy.run_blockwise( + task = daisy.Task( + "linajea_prediction", input_roi, block_read_roi, block_write_roi, @@ -141,11 +143,11 @@ def predict_blockwise(linajea_config): max_retries=0, fit='valid') + daisy.run_blockwise([task]) + def predict_worker(linajea_config): - worker_id = daisy.Context.from_env().worker_id - worker_time = time.time() job = linajea_config.predict.job script_dir = os.path.join( @@ -174,21 +176,15 @@ def predict_worker(linajea_config): expand=False, flags=['-P ' + job.lab] if job.lab is not None else None ) - elif os.path.isdir("/fast/work/users"): - cmd = ['sbatch', '../run_slurm_gpu.sh'] + command[1:] + elif job.run_on == "slurm": + cmd = ['sbatch', '../run_slurm_gpu.sh'] + command.split(" ")[1:] + elif job.run_on == "gridengine": + cmd = ['qsub', '../run_gridengine_gpu.sh'] + command.split(" ")[1:] else: raise RuntimeError("cannot detect hpc system!") logger.info("Starting predict worker...") - cmd = ["\"{}\"".format(c) if "affinity" in c else c for c in cmd] - cmd = ["\"{}\"".format(c) if "rusage" in c else c for c in cmd] logger.info("Command: %s" % str(cmd)) - os.makedirs('logs', exist_ok=True) - daisy.call( - cmd, - log_out='logs/predict_%s_%d_%d.out' % (linajea_config.general.setup, - worker_time, worker_id), - log_err='logs/predict_%s_%d_%d.err' % (linajea_config.general.setup, - worker_time, worker_id)) + subprocess.run(cmd, shell=True) logger.info("Predict worker finished") diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index dd08974..cea0b8f 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -1,8 +1,9 @@ import logging +import subprocess import time import daisy -from linajea import CandidateDatabase +from linajea.utils import CandidateDatabase from .daisy_check_functions import ( check_function, write_done, check_function_all_blocks, write_done_all_blocks) @@ -60,7 +61,7 @@ def solve_blockwise(linajea_config): if check_function_all_blocks(step_name, db_name, db_host): logger.info("Param set with name %s already completed. Exiting", step_name) - return True + return parameters_id else: step_name = 'solve_' + str(parameters_id[0]) # Check each individual parameter to see if it is done @@ -77,9 +78,10 @@ def solve_blockwise(linajea_config): logger.debug(parameters_id) if len(parameters_id) == 0: logger.info("All parameters in set already completed. Exiting") - return True + return parameters_id - success = daisy.run_blockwise( + task = daisy.Task( + "linajea_solving", total_roi, block_read_roi, block_write_roi, @@ -98,6 +100,8 @@ def solve_blockwise(linajea_config): db_host), num_workers=linajea_config.solve.job.num_workers, fit='overhang') + + success = daisy.run_blockwise([task]) if success: # write all done to individual parameters and set if len(param_names) > 1: @@ -111,7 +115,7 @@ def solve_blockwise(linajea_config): db_name, db_host) logger.info("Finished solving") - return success + return parameters_id if success else success def solve_in_block(linajea_config, @@ -146,7 +150,7 @@ def solve_in_block(linajea_config, logger.debug("Write roi: %s", str(write_roi)) - if write_roi.empty(): + if write_roi.empty: logger.info("Write roi empty, skipping block %d", block.block_id) write_done(block, step_name, db_name, db_host) return 0 @@ -201,7 +205,7 @@ def solve_in_block(linajea_config, nm_track(graph, linajea_config, selected_keys, frames=frames) else: track(graph, linajea_config, selected_keys, - frames=frames, block_id=block.block_id) + frames=frames, block_id=block.block_id[1]) if linajea_config.solve.write_struct_svm: logger.info("wrote struct svm data, database not updated") From 2f26dc66efb70788954da054676b5974896ed481 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 10:05:10 -0400 Subject: [PATCH 191/263] add run/tutorial scripts --- linajea/run_scripts/01_train.py | 36 + linajea/run_scripts/02_predict_blockwise.py | 35 + linajea/run_scripts/03_extract_edges.py | 33 + linajea/run_scripts/04_solve.py | 41 + linajea/run_scripts/05_evaluate.py | 38 + linajea/run_scripts/config_example.toml | 298 +++ .../config_example_drosophila.toml | 229 ++ linajea/run_scripts/config_example_mouse.toml | 229 ++ .../config_example_parameters.toml | 11 + .../config_example_single_sample_model.toml | 38 + .../config_example_single_sample_predict.toml | 16 + .../config_example_single_sample_test.toml | 50 + .../config_example_single_sample_train.toml | 167 ++ .../config_example_single_sample_val.toml | 51 + linajea/run_scripts/run.ipynb | 1990 +++++++++++++++++ linajea/run_scripts/run.py | 346 +++ linajea/run_scripts/run_single_sample.ipynb | 359 +++ 17 files changed, 3967 insertions(+) create mode 100644 linajea/run_scripts/01_train.py create mode 100644 linajea/run_scripts/02_predict_blockwise.py create mode 100644 linajea/run_scripts/03_extract_edges.py create mode 100644 linajea/run_scripts/04_solve.py create mode 100644 linajea/run_scripts/05_evaluate.py create mode 100644 linajea/run_scripts/config_example.toml create mode 100644 linajea/run_scripts/config_example_drosophila.toml create mode 100644 linajea/run_scripts/config_example_mouse.toml create mode 100644 linajea/run_scripts/config_example_parameters.toml create mode 100644 linajea/run_scripts/config_example_single_sample_model.toml create mode 100644 linajea/run_scripts/config_example_single_sample_predict.toml create mode 100644 linajea/run_scripts/config_example_single_sample_test.toml create mode 100644 linajea/run_scripts/config_example_single_sample_train.toml create mode 100644 linajea/run_scripts/config_example_single_sample_val.toml create mode 100644 linajea/run_scripts/run.ipynb create mode 100755 linajea/run_scripts/run.py create mode 100644 linajea/run_scripts/run_single_sample.ipynb diff --git a/linajea/run_scripts/01_train.py b/linajea/run_scripts/01_train.py new file mode 100644 index 0000000..afde0bc --- /dev/null +++ b/linajea/run_scripts/01_train.py @@ -0,0 +1,36 @@ +import argparse +import logging +import os +import sys +import time + +from linajea.config import (TrackingConfig, + maybe_fix_config_paths_to_machine_and_load) +from linajea.utils import print_time +from linajea.training import train + +logger = logging.getLogger(__name__) + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser() + parser.add_argument('--config', type=str, + help='path to config file') + args = parser.parse_args() + + config = maybe_fix_config_paths_to_machine_and_load(args.config) + config = TrackingConfig(**config) + logging.basicConfig( + level=config.general.logging, + handlers=[ + logging.FileHandler(os.path.join(config.general.setup_dir, 'run.log'), + mode='a'), + logging.StreamHandler(sys.stdout), + ], + format='%(asctime)s %(name)s %(levelname)-8s %(message)s') + + start_time = time.time() + train(config) + end_time = time.time() + print_time(end_time - start_time) diff --git a/linajea/run_scripts/02_predict_blockwise.py b/linajea/run_scripts/02_predict_blockwise.py new file mode 100644 index 0000000..750947d --- /dev/null +++ b/linajea/run_scripts/02_predict_blockwise.py @@ -0,0 +1,35 @@ +from __future__ import absolute_import +import argparse +import logging +import time + +from linajea.utils import (print_time, + getNextInferenceData) +from linajea.process_blockwise import predict_blockwise + + +logging.basicConfig( + # wrong crop: switch + level=logging.INFO, + # level=logging.DEBUG, + format='%(asctime)s %(name)s %(levelname)-8s %(message)s') +logger = logging.getLogger(__name__) + +if __name__ == "__main__": + + parser = argparse.ArgumentParser() + parser.add_argument('--config', type=str, + help='path to config file') + parser.add_argument('--checkpoint', type=int, default=-1, + help='checkpoint/iteration to predict') + parser.add_argument('--validation', action="store_true", + help='use validation data?') + parser.add_argument('--validate_on_train', action="store_true", + help='validate on train data?') + args = parser.parse_args() + + start_time = time.time() + for inf_config in getNextInferenceData(args): + predict_blockwise(inf_config) + end_time = time.time() + print_time(end_time - start_time) diff --git a/linajea/run_scripts/03_extract_edges.py b/linajea/run_scripts/03_extract_edges.py new file mode 100644 index 0000000..e03d1c9 --- /dev/null +++ b/linajea/run_scripts/03_extract_edges.py @@ -0,0 +1,33 @@ +from __future__ import absolute_import +import argparse +import logging +import time + +from linajea.utils import (print_time, + getNextInferenceData) +from linajea.process_blockwise import extract_edges_blockwise + + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(name)s %(levelname)-8s %(message)s') +logger = logging.getLogger(__name__) + +if __name__ == "__main__": + + parser = argparse.ArgumentParser() + parser.add_argument('--config', type=str, + help='path to config file') + parser.add_argument('--checkpoint', type=int, default=-1, + help='checkpoint to process') + parser.add_argument('--validation', action="store_true", + help='use validation data?') + parser.add_argument('--validate_on_train', action="store_true", + help='validate on train data?') + args = parser.parse_args() + + start_time = time.time() + for inf_config in getNextInferenceData(args): + extract_edges_blockwise(inf_config) + end_time = time.time() + print_time(end_time - start_time) diff --git a/linajea/run_scripts/04_solve.py b/linajea/run_scripts/04_solve.py new file mode 100644 index 0000000..9aa0774 --- /dev/null +++ b/linajea/run_scripts/04_solve.py @@ -0,0 +1,41 @@ +from __future__ import absolute_import +import argparse +import logging +import time + +from linajea.utils import (print_time, + getNextInferenceData) +from linajea.process_blockwise import solve_blockwise + + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(name)s %(levelname)-8s %(message)s') +logger = logging.getLogger(__name__) + +if __name__ == "__main__": + + parser = argparse.ArgumentParser() + parser.add_argument('--config', type=str, + help='path to config file') + parser.add_argument('--checkpoint', type=int, default=-1, + help='checkpoint to process') + parser.add_argument('--validation', action="store_true", + help='use validation data?') + parser.add_argument('--validate_on_train', action="store_true", + help='validate on train data?') + parser.add_argument('--val_param_id', type=int, default=None, + help='get test parameters from validation parameters_id') + parser.add_argument('--param_id', type=int, default=None, + help='process parameters with parameters_id (e.g. resolve set of parameters)') + parser.add_argument('--param_ids', default=None, nargs=2, + help='start/end range of eval parameters_ids') + parser.add_argument('--param_list_idx', type=str, default=None, + help='only solve idx parameter set in config') + args = parser.parse_args() + + start_time = time.time() + for inf_config in getNextInferenceData(args, is_solve=True): + solve_blockwise(inf_config) + end_time = time.time() + print_time(end_time - start_time) diff --git a/linajea/run_scripts/05_evaluate.py b/linajea/run_scripts/05_evaluate.py new file mode 100644 index 0000000..d9f80bf --- /dev/null +++ b/linajea/run_scripts/05_evaluate.py @@ -0,0 +1,38 @@ +import argparse +import logging +import time + +from linajea.utils import (print_time, + getNextInferenceData) +import linajea.evaluation + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(name)s %(levelname)-8s %(message)s') +logger = logging.getLogger(__name__) + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser() + parser.add_argument('--config', type=str, + help='path to config file') + parser.add_argument('--checkpoint', type=int, default=-1, + help='checkpoint to process') + parser.add_argument('--validation', action="store_true", + help='use validation data?') + parser.add_argument('--validate_on_train', action="store_true", + help='validate on train data?') + parser.add_argument('--val_param_id', type=int, default=None, + help='get test parameters from validation parameters_id') + parser.add_argument('--param_id', default=None, + help='process parameters with parameters_id') + parser.add_argument('--param_list_idx', type=str, default=None, + help='only eval parameters[idx] in config') + args = parser.parse_args() + + start_time = time.time() + for inf_config in getNextInferenceData(args, is_evaluate=True): + linajea.evaluation.evaluate_setup(inf_config) + end_time = time.time() + print_time(end_time - start_time) diff --git a/linajea/run_scripts/config_example.toml b/linajea/run_scripts/config_example.toml new file mode 100644 index 0000000..82025bf --- /dev/null +++ b/linajea/run_scripts/config_example.toml @@ -0,0 +1,298 @@ +[general] +logging = 20 +db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin?replicaSet=rsLinajea" +seed = 42 +setup_dir = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/experiments/mskc_test_1" +sparse = false + +[model] +train_input_shape = [ 7, 40, 148, 148,] +# train_input_shape = [ 7, 40, 260, 260,] +predict_input_shape = [ 7, 80, 260, 260,] +unet_style = "split" +num_fmaps = [12, 12] +# fmap_inc_factors = [4, 4, 8] +fmap_inc_factors = 4 +downsample_factors = [[ 1, 2, 2,], + [ 1, 2, 2,], + [ 2, 2, 2,],] +# kernel_size_down = [[ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],],] +kernel_size_down = [[ [3, 3, 3, 3], [3, 3, 3, 3],], + [ [3, 3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],],] +# kernel_size_down = [[ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3, 3],], +# [ [3, 3, 3, 3], [3, 3, 3, 3],],] +kernel_size_up = [[ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],],] +# constant_upsample = true +# upsampling = "pixel_shuffle" +# upsampling = "trilinear" +upsampling = "sep_transposed_conv" +average_vectors = false +# nms_window_shape = [ 3, 11, 11,] +nms_window_shape = [ 3, 9, 9,] +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/mknet_celegans.py" +cell_indicator_weighted = 0.01 +cell_indicator_cutoff = 0.01 +# cell_indicator_weighted = true +latent_temp_conv = false +train_only_cell_indicator = false + +[train] +val_log_step = 25 +# radius for binary map -> *2 (in world units) +# in which to draw parent vectors (not used if use_radius) +parent_radius = [ 0.1, 8.0, 8.0, 8.0,] +# upper bound for dist cell moved between two frames (needed for context) +move_radius = 25 +# sigma for Gauss -> ~*4 (5 in z -> in 3 slices) (not used if use_radius) +rasterize_radius = [ 0.1, 5.0, 3.0, 3.0,] +cache_size = 1 +parent_vectors_loss_transition_offset = 20000 +parent_vectors_loss_transition_factor = 0.001 +#use_radius = true +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/train_val_celegans_torch.py" +max_iterations = 11 +checkpoint_stride = 10 +snapshot_stride = 5 +profiling_stride = 10 +use_auto_mixed_precision = true +use_swa = true +swa_every_it = false +swa_start_it = 49999 +swa_freq_it = 1000 +use_grad_norm = true + +[train.use_radius] +30 = 20 +70 = 15 +100 = 13 +130 = 11 +180 = 10 +270 = 8 +9999 = 7 + +[optimizerTorch] +optimizer = "Adam" +# optimizer = "SGD" + +[extract] +block_size = [ 5, 512, 512, 512,] + +[solve] +from_scratch = true +non_minimal = false +greedy = false +# write_struct_svm = "ssvm_ckpt_200000" +check_node_close_to_roi = false +add_node_density_constraints = false +# random_search = false +# [solve.add_node_density_constraints] +# 30 = 13 +# 60 = 11 +# 10000 = 7 + + +[[solve.parameters]] +# # # ssvm 240k w class id 507 (wo class ) +weight_node_score = -16.786125363574673 +selection_constant = 8.893522542354866 +track_cost = 20.675083335777686 +# disappear cost -0.0 +weight_division = -8.260920358667896 +division_constant = 5.9535746121046165 +weight_child = 1.003401421433546 +weight_continuation = -1.0264581546174496 +weight_edge_score = 0.4254227576366175 + +# cell_cycle_key = '201112_063001/test/10000_' +block_size = [15, 512, 512, 712] +context = [2, 100, 100, 100] + + + +[solve.parameters_search_grid] + +weight_node_score = [-13, -17, -21] +selection_constant = [6, 9, 12] +# track_cost = [7, 11, 15] +track_cost = [7,] +# disappear cost -0.0 +# weight_division = [-8, -11, 0] +# division_constant = [6.0, 2.5, 1] +# weight_child = [0.0, 1.0, 2.0] +# weight_continuation = [0.0, -1.0] +# weight_edge_score = [0.1, 0.35] +weight_division = [-8, -11] +# division_constant = [6.0, 2.5, 1.0] +division_constant = [6.0, 2.5] +weight_child = [1.0, 2.0] +weight_continuation = [-1.0] +# weight_edge_score = [0.1, 0.35] +weight_edge_score = [0.35] + +# cell_cycle_key = ['201112_063001/test/10000_', ''] +# cell_cycle_key = ['201112_063001/test/10000_'] # +val = [true] +block_size = [[15, 512, 512, 712]] +context = [[2, 100, 100, 100]] +num_configs = 5 + + +[evaluate] +from_scratch = false + +[predict] +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/predict_celegans_torch.py" +path_to_script_db_from_zarr = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/write_cells_celegans.py" +output_zarr_prefix = "/nrs/funke/hirschp/linajea_experiments" +write_to_zarr = false +write_to_db = true +write_db_from_zarr = false +processes_per_worker = 1 + +[train_data] +voxel_size = [ 1, 5, 1, 1,] +[[train_data.data_sources]] +gt_db_name = "linajea_celegans_emb1_fixed" + +[train_data.data_sources.datafile] +filename = "/nrs/funke/hirschp/mskcc_emb1" +group = "volumes/raw_nested" +# [[train_data.data_sources]] +# gt_db_name = "linajea_celegans_emb3_fixed" + +# [train_data.data_sources.datafile] +# filename = "/nrs/funke/hirschp/mskcc_emb3" +# group = "volumes/raw_nested" + +[test_data] +checkpoint = 10 +cell_score_threshold = 0.2 +voxel_size = [ 1, 5, 1, 1,] +[[test_data.data_sources]] +gt_db_name = "linajea_celegans_emb3_fixed" + +[test_data.data_sources.datafile] +filename = "/nrs/funke/hirschp/mskcc_emb3" +group = "volumes/raw_nested" + +[validate_data] +checkpoints = [ 10,] +cell_score_threshold = 0.2 +voxel_size = [ 1, 5, 1, 1,] +[[validate_data.data_sources]] +gt_db_name = "linajea_celegans_emb_fixed" + +[validate_data.data_sources.datafile] +filename = "/nrs/funke/hirschp/mskcc_emb" +group = "volumes/raw_nested" + +[train.job] +num_workers = 1 +queue = "gpu_tesla" + +[train.augment] +divisions = true +reject_empty_prob = 0.9 +normalization = "minmax" +perc_min = "perc0_01" +perc_max = "perc99_99" +norm_bounds = [ 2000, 7500,] +point_balance_radius = 75 + +[optimizerTorch.kwargs] +lr = 5e-5 +betas = [0.95, 0.999] +eps = 1e-8 +amsgrad = false +# weight_decay = 1e-4 +# momentum = 0.9 +# nesterov = true + +[extract.edge_move_threshold] +50 = 45 +1000 = 35 + +[extract.job] +num_workers = 1 +queue = "local" + +[solve.job] +num_workers = 1 +queue = "local" + +[evaluate.parameters] +matching_threshold = 15 +sparse = false + +[evaluate.job] +num_workers = 1 +queue = "local" + +[predict.job] +num_workers = 1 +queue = "gpu_tesla" + +[train_data.roi] +offset = [ 50, 0, 0, 0,] +shape = [ 10, 205, 512, 512,] + +[test_data.roi] +offset = [ 50, 0, 0, 0,] +shape = [ 10, 205, 512, 512,] + +[validate_data.roi] +offset = [ 50, 0, 0, 0,] +shape = [ 10, 205, 512, 512,] + +[train.augment.elastic] +control_point_spacing = [ 5, 25, 25,] +# control_point_spacing = [ 3, 15, 15,] +jitter_sigma = [ 1, 1, 1,] +rotation_min = -45 +rotation_max = 45 +rotation_3d = false +subsample = 4 +use_fast_points_transform = true + +[train.augment.zoom] +factor_min = 0.75 +factor_max = 1.5 +spatial_dims = 2 + +[train.augment.shift] +prob_slip = 0.2 +prob_shift = 0.2 +sigma = [ 0, 4, 4, 4,] + +[train.augment.intensity] +scale = [ 0.9, 1.1,] +shift = [ -0.001, 0.001,] + +[train.augment.simple] +mirror = [ 2, 3,] +transpose = [ 2, 3,] + +# # check snapshots per dataset for good value! +# # maybe just use speckle (multiplicative gaussian noise) +# # instead of this (additive gaussian noise) +# [train.augment.noise_gaussian] +# var = [ 0.001,] + +[train.augment.noise_saltpepper] +amount = [ 0.0001,] + +[train.augment.noise_speckle] +var = [ 0.05,] + +[train.augment.histogram] +range_low = 0.1 +range_high = 1.0 diff --git a/linajea/run_scripts/config_example_drosophila.toml b/linajea/run_scripts/config_example_drosophila.toml new file mode 100644 index 0000000..884e753 --- /dev/null +++ b/linajea/run_scripts/config_example_drosophila.toml @@ -0,0 +1,229 @@ +[general] +logging = 20 +db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin?replicaSet=rsLinajea" +seed = 42 +sparse = true +setup_dir = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/experiments/droso_test_1" + +[model] +train_input_shape = [ 7, 60, 148, 148,] +predict_input_shape = [ 7, 80, 260, 260,] +unet_style = "split" +num_fmaps = [12, 12] +fmap_inc_factors = 3 +downsample_factors = [[ 1, 2, 2,], + [ 2, 2, 2,], + [ 2, 2, 2,],] +kernel_size_down = [[ [3, 3, 3, 3], [3, 3, 3, 3],], + [ [3, 3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],],] +kernel_size_up = [[ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],],] +upsampling = "trilinear" +average_vectors = false +nms_window_shape = [ 3, 15, 15,] +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/mknet_drosophila.py" +latent_temp_conv = false +train_only_cell_indicator = false + + +[train] +# radius for binary map -> *2 (in world units) +# in which to draw parent vectors (not used if use_radius) +parent_radius = [ 0.1, 10.0, 10.0, 10.0,] +# sigma for Gauss -> ~*4 (5 in z -> in 3 slices) (not used if use_radius) +rasterize_radius = [ 0.1, 5.0, 5.0, 5.0,] +# upper bound for dist cell moved between two frames (needed for context) +move_radius = 10 +cache_size = 1 +use_radius = false +parent_vectors_loss_transition_offset = 20000 +parent_vectors_loss_transition_factor = 0.01 +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/train_drosophila.py" +max_iterations = 11 +checkpoint_stride = 10 +snapshot_stride = 5 +profiling_stride = 10 +use_auto_mixed_precision = false +use_swa = false +swa_every_it = false +swa_start_it = 49999 +swa_freq_it = 1000 +use_grad_norm = false +[train.job] +num_workers = 1 +queue = "gpu_tesla" + +[train.augment] +divisions = false +reject_empty_prob = 1.0 +normalization = "default" +point_balance_radius = 1 + +[train.augment.elastic] +control_point_spacing = [ 5, 10, 10,] +jitter_sigma = [ 1, 1, 1,] +rotation_min = 0 +rotation_max = 90 +rotation_3d = false +subsample = 8 +use_fast_points_transform = false + +[train.augment.intensity] +scale = [ 0.9, 1.1,] +shift = [ -0.001, 0.001,] + +[train.augment.simple] +mirror = [ 1, 2, 3,] +transpose = [ 2, 3,] + + +[optimizerTorch] +optimizer = "Adam" +[optimizerTorch.kwargs] +lr = 5e-4 +betas = [0.95, 0.999] +eps = 1e-8 + + +[predict] +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/predict.py" +path_to_script_db_from_zarr = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/write_cells.py" +output_zarr_prefix = "/nrs/funke/hirschp/linajea_experiments" +write_to_zarr = false +write_to_db = true +write_db_from_zarr = false +processes_per_worker = 1 +use_swa = false +[predict.job] +num_workers = 1 +queue = "gpu_tesla" + +[extract] +block_size = [ 7, 500, 500, 500,] +# edge_move_threshold = 40 +use_pv_distance = true +[extract.edge_move_threshold] +-1 = 25 + + +[extract.job] +num_workers = 1 +queue = "local" + +[solve] +from_scratch = true +non_minimal = false +greedy = false +write_struct_svm = false +check_node_close_to_roi = false +add_node_density_constraints = false +[solve.job] +num_workers = 1 +queue = "local" +[[solve.parameters]] +weight_node_score = -0.1 +selection_constant = -0.425 +track_cost = 1.35 +division_constant = 1 +weight_edge_score = 0.0425 +# weight_division = +# weight_child = +# weight_continuation = + +# cell_cycle_key = '' +block_size = [5, 500, 500, 500] +context = [2, 100, 100, 100] + +# [solve.parameters_search_random] + +# # weight_node_score = [] +# # selection_constant = [] +# # track_cost = [] +# # weight_division = [] +# # division_constant = [] +# # weight_child = [] +# # weight_continuation = [] +# # weight_edge_score = [] + +# # cell_cycle_key = [] +# block_size = [[5, 500, 500, 500]] +# context = [[2, 100, 100, 100]] +# num_configs = 25 +[solve.parameters_search_grid] +weight_node_score = [-0.1, -0.05, -0.0, 0.05] +selection_constant = [-0.35, -0.375, -0.4, -0.425] +track_cost = [1.35, 1.375, 1.4, 1.425] +# weight_division = [] +division_constant = [1.0] +# weight_child = [] +# weight_continuation = [] +weight_edge_score = [0.035, 0.0375, 0.04, 0.0425] + +# cell_cycle_key = [] +block_size = [[5, 500, 500, 500]] +context = [[2, 100, 100, 100]] +num_configs = 25 + +[evaluate] +from_scratch = false +[evaluate.parameters] +matching_threshold = 15 +window_size = 200 +validation_score = false +ignore_one_off_div_errors = false +fn_div_count_unconnected_parent = true +sparse = true +filter_polar_bodies = false +# [evaluate.parameters.roi] +# offset = [ 0, 0, 0, 0,] +# shape = [ 270, 512, 512, 512,] +# shape = [ 200, 512, 512, 512,] +[evaluate.job] +num_workers = 1 +queue = "local" + +[train_data] +voxel_size = [ 1, 5, 1, 1,] +[[train_data.data_sources]] +gt_db_name = "linajea_120828_gt_side_1" +exclude_times = [[200, 250]] +tracksfile = "/groups/funke/home/hirschp/linajea_experiments/data/120828/tracks/tracks_side_1_div_state.txt" +[train_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group = "raw" +[train_data.roi] +offset = [ 0, 0, 0, 0,] +shape = [ 450, 625, 603, 1272,] + +[validate_data] +checkpoints = [ 400000,] +cell_score_threshold = 0.4 +voxel_size = [ 1, 5, 1, 1,] +[[validate_data.data_sources]] +gt_db_name = "linajea_120828_gt_side_1" +db_name = "linajea_120828_setup211_simple_vald_side_1_400000_te" +tracksfile = "/groups/funke/home/hirschp/linajea_experiments/data/120828/tracks/tracks_side_1_div_state.txt" +[validate_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group = "raw" +[validate_data.roi] +offset = [ 200, 0, 0, 0,] +shape = [ 50, 625, 603, 1272,] + +[test_data] +checkpoint = 10 +cell_score_threshold = 0.4 +voxel_size = [ 1, 5, 1, 1,] +[[test_data.data_sources]] +gt_db_name = "linajea_120828_gt_side_2" +db_name = "linajea_120828_setup211_simple_eval_side_2_400000_te" +tracksfile = "/groups/funke/home/hirschp/linajea_experiments/data/120828/tracks/tracks_side_2_div_state.txt" +[test_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/120828/120828.n5" +group = "raw" +[test_data.roi] +offset = [ 50, 0, 0, 0,] +shape = [ 5, 625, 603, 1272,] diff --git a/linajea/run_scripts/config_example_mouse.toml b/linajea/run_scripts/config_example_mouse.toml new file mode 100644 index 0000000..2555bb0 --- /dev/null +++ b/linajea/run_scripts/config_example_mouse.toml @@ -0,0 +1,229 @@ +[general] +logging = 20 +db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin?replicaSet=rsLinajea" +seed = 42 +sparse = true +setup_dir = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/experiments/mouse_test_1" + +[model] +train_input_shape = [ 7, 60, 148, 148,] +predict_input_shape = [ 7, 80, 260, 260,] +unet_style = "split" +num_fmaps = [12, 12] +fmap_inc_factors = 3 +downsample_factors = [[ 1, 2, 2,], + [ 2, 2, 2,], + [ 2, 2, 2,],] +kernel_size_down = [[ [3, 3, 3, 3], [3, 3, 3, 3],], + [ [3, 3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],],] +kernel_size_up = [[ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],],] +upsampling = "trilinear" +average_vectors = false +nms_window_shape = [ 5, 21, 21,] +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/mknet_mouse.py" +latent_temp_conv = false +train_only_cell_indicator = false + + +[train] +# radius for binary map -> *2 (in world units) +# in which to draw parent vectors (not used if use_radius) +parent_radius = [ 0.1, 10.0, 10.0, 10.0,] +# sigma for Gauss -> ~*4 (5 in z -> in 3 slices) (not used if use_radius) +rasterize_radius = [ 0.1, 5.0, 5.0, 5.0,] +# upper bound for dist cell moved between two frames (needed for context) +move_radius = 10 +cache_size = 40 +use_radius = false +parent_vectors_loss_transition_offset = 20000 +parent_vectors_loss_transition_factor = 0.01 +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/train_mouse.py" +max_iterations = 400001 +checkpoint_stride = 25000 +snapshot_stride = 1000 +profiling_stride = 10 +use_auto_mixed_precision = false +use_swa = false +swa_every_it = false +swa_start_it = 49999 +swa_freq_it = 1000 +use_grad_norm = false +[train.job] +num_workers = 10 +queue = "gpu_tesla" + +[train.augment] +divisions = false +reject_empty_prob = 1.0 +normalization = "default" +point_balance_radius = 1 + +[train.augment.elastic] +control_point_spacing = [ 5, 10, 10,] +jitter_sigma = [ 1, 1, 1,] +rotation_min = 0 +rotation_max = 90 +rotation_3d = false +subsample = 8 +use_fast_points_transform = false + +[train.augment.intensity] +scale = [ 0.9, 1.1,] +shift = [ -0.001, 0.001,] + +[train.augment.simple] +mirror = [ 1, 2, 3,] +transpose = [ 2, 3,] + + +[optimizerTorch] +optimizer = "Adam" +[optimizerTorch.kwargs] +lr = 5e-4 +betas = [0.95, 0.999] +eps = 1e-8 + + +[predict] +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/predict.py" +path_to_script_db_from_zarr = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/write_cells.py" +output_zarr_prefix = "/nrs/funke/hirschp/linajea_experiments" +write_to_zarr = false +write_to_db = true +write_db_from_zarr = false +processes_per_worker = 10 +use_swa = false +[predict.job] +num_workers = 4 +queue = "gpu_tesla" + +[extract] +block_size = [ 5, 500, 500, 500,] +# edge_move_threshold = 40 +use_pv_distance = true +[extract.edge_move_threshold] +-1 = 40 + + +[extract.job] +num_workers = 32 +queue = "local" + +[solve] +from_scratch = true +non_minimal = false +greedy = false +write_struct_svm = false +check_node_close_to_roi = false +add_node_density_constraints = false +[solve.job] +num_workers = 4 +queue = "local" +[[solve.parameters]] +weight_node_score = -0.01 +selection_constant = -0.3 +track_cost = 0.6 +division_constant = 1 +weight_edge_score = 0.02 +# weight_division = +# weight_child = +# weight_continuation = + +# cell_cycle_key = '' +block_size = [5, 500, 500, 500] +context = [2, 100, 100, 100] + +# [solve.parameters_search_random] + +# # weight_node_score = [] +# # selection_constant = [] +# # track_cost = [] +# # weight_division = [] +# # division_constant = [] +# # weight_child = [] +# # weight_continuation = [] +# # weight_edge_score = [] + +# # cell_cycle_key = [] +# block_size = [[5, 500, 500, 500]] +# context = [[2, 100, 100, 100]] +# num_configs = 25 +[solve.parameters_search_grid] +weight_node_score = [0.0, -0.01, -0.02, -0.03] +selection_constant = [-0.2, -0.25, -0.3, -0.35] +track_cost = [0.55, 0.575, 0.6, 0.625] +# weight_division = [] +division_constant = [1.0] +# weight_child = [] +# weight_continuation = [] +weight_edge_score = [0.0, 0.01, 0.02, 0.03] + +# cell_cycle_key = [] +block_size = [[5, 500, 500, 500]] +context = [[2, 100, 100, 100]] +num_configs = 25 + +[evaluate] +from_scratch = false +[evaluate.parameters] +matching_threshold = 20 +window_size = 50 +validation_score = false +ignore_one_off_div_errors = false +fn_div_count_unconnected_parent = true +sparse = true +filter_polar_bodies = false +# [evaluate.parameters.roi] +# offset = [ 0, 0, 0, 0,] +# shape = [ 270, 512, 512, 512,] +# shape = [ 200, 512, 512, 512,] +[evaluate.job] +num_workers = 1 +queue = "local" + +[train_data] +voxel_size = [ 1, 5, 1, 1,] +[[train_data.data_sources]] +gt_db_name = "linajea_140521_gt" +exclude_times = [[50, 100], + [225, 275]] +[train_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/140521/140521.n5" +group = "raw" +[train_data.roi] +offset = [ 0, 0, 0, 0,] +shape = [ 532, 4940, 2048, 2169,] + +[validate_data] +checkpoints = [ 400000,] +cell_score_threshold = 0.4 +voxel_size = [ 1, 5, 1, 1,] +[[validate_data.data_sources]] +gt_db_name = "linajea_140521_gt_middle" +db_name = "linajea_140521_setup11_simple_late_middle_400000_te" +tracksfile = "/groups/funke/home/hirschp/linajea_experiments/data/140521/tracks/extended_tracks_div_state.txt" +[validate_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/140521/140521.n5" +group = "raw" +[validate_data.roi] +offset = [ 225, 0, 0, 0,] +shape = [ 50, 4940, 2048, 2169,] + +[test_data] +checkpoint = 400000 +cell_score_threshold = 0.4 +voxel_size = [ 1, 5, 1, 1,] +[[test_data.data_sources]] +gt_db_name = "linajea_140521_gt_early" +db_name = "linajea_140521_setup11_simple_late_early_400000_te" +tracksfile = "/groups/funke/home/hirschp/linajea_experiments/data/140521/tracks/extended_tracks_div_state.txt" +[test_data.data_sources.datafile] +filename = "/nrs/funke/malinmayorc/140521/140521.n5" +group = "raw" +[test_data.roi] +offset = [ 50, 0, 0, 0,] +shape = [ 50, 4940, 2048, 2169,] diff --git a/linajea/run_scripts/config_example_parameters.toml b/linajea/run_scripts/config_example_parameters.toml new file mode 100644 index 0000000..fd0772a --- /dev/null +++ b/linajea/run_scripts/config_example_parameters.toml @@ -0,0 +1,11 @@ +weight_node_score = [-13, -17, -21] +selection_constant = [6, 9, 12] +track_cost = [7,] +weight_division = [-8, -11] +division_constant = [6.0, 2.5] +weight_child = [1.0, 2.0] +weight_continuation = [-1.0] +weight_edge_score = [0.35] +val = [true] +block_size = [[15, 512, 512, 712]] +context = [[2, 100, 100, 100]] \ No newline at end of file diff --git a/linajea/run_scripts/config_example_single_sample_model.toml b/linajea/run_scripts/config_example_single_sample_model.toml new file mode 100644 index 0000000..55f5a03 --- /dev/null +++ b/linajea/run_scripts/config_example_single_sample_model.toml @@ -0,0 +1,38 @@ +train_input_shape = [ 7, 40, 148, 148,] +# train_input_shape = [ 7, 40, 260, 260,] +predict_input_shape = [ 7, 80, 260, 260,] +unet_style = "split" +num_fmaps = [12, 12] +# fmap_inc_factors = [4, 4, 8] +fmap_inc_factors = 4 +downsample_factors = [[ 1, 2, 2,], + [ 1, 2, 2,], + [ 2, 2, 2,],] +# kernel_size_down = [[ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],],] +kernel_size_down = [[ [3, 3, 3, 3], [3, 3, 3, 3],], + [ [3, 3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],],] +# kernel_size_down = [[ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3, 3],], +# [ [3, 3, 3, 3], [3, 3, 3, 3],],] +kernel_size_up = [[ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],],] +# constant_upsample = true +# upsampling = "pixel_shuffle" +# upsampling = "trilinear" +upsampling = "sep_transposed_conv" +average_vectors = false +# nms_window_shape = [ 3, 11, 11,] +nms_window_shape = [ 3, 9, 9,] +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/mknet_celegans.py" +cell_indicator_weighted = 0.01 +cell_indicator_cutoff = 0.01 +# cell_indicator_weighted = true +latent_temp_conv = false +train_only_cell_indicator = false diff --git a/linajea/run_scripts/config_example_single_sample_predict.toml b/linajea/run_scripts/config_example_single_sample_predict.toml new file mode 100644 index 0000000..4444953 --- /dev/null +++ b/linajea/run_scripts/config_example_single_sample_predict.toml @@ -0,0 +1,16 @@ +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/predict_celegans_torch.py" +path_to_script_db_from_zarr = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/write_cells_celegans.py" +output_zarr_prefix = "/nrs/funke/hirschp/linajea_experiments" +write_to_zarr = false +write_to_db = true +write_db_from_zarr = false +processes_per_worker = 1 +[normalization] +type = "minmax" +perc_min = "perc0_01" +perc_max = "perc99_99" +norm_bounds = [ 2000, 7500,] + +[job] +num_workers = 1 +queue = "gpu_tesla" diff --git a/linajea/run_scripts/config_example_single_sample_test.toml b/linajea/run_scripts/config_example_single_sample_test.toml new file mode 100644 index 0000000..9f684a9 --- /dev/null +++ b/linajea/run_scripts/config_example_single_sample_test.toml @@ -0,0 +1,50 @@ +model = "config_example_single_sample_model.toml" +predict = "config_example_single_sample_predict.toml" + +[general] +logging = 20 +db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin?replicaSet=rsLinajea" +seed = 42 +setup_dir = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/experiments/mskc_test_1" +sparse = false + + +[extract] +block_size = [ 5, 512, 512, 512,] + +[solve] +from_scratch = true +check_node_close_to_roi = false +parameters = "test.toml" + + +[evaluate] +from_scratch = false + + +[inference_data] +# checkpoint = 10 +# cell_score_threshold = 0.2 +[inference_data.data_source] +gt_db_name = "linajea_celegans_emb3_fixed" +voxel_size = [ 1, 5, 1, 1,] +db_name = "linajea_celegans_20220623_180937" +[inference_data.data_source.datafile] +filename = "/nrs/funke/hirschp/mskcc_emb3" +group = "volumes/raw_nested" +[inference_data.data_source.roi] +offset = [ 50, 0, 0, 0,] +shape = [ 10, 205, 512, 512,] + +[extract.edge_move_threshold] +50 = 45 +1000 = 35 + +[extract.job] +num_workers = 1 +queue = "local" + +[evaluate.parameters] +matching_threshold = 15 +sparse = false + diff --git a/linajea/run_scripts/config_example_single_sample_train.toml b/linajea/run_scripts/config_example_single_sample_train.toml new file mode 100644 index 0000000..03a53fe --- /dev/null +++ b/linajea/run_scripts/config_example_single_sample_train.toml @@ -0,0 +1,167 @@ +[general] +logging = 20 +db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin?replicaSet=rsLinajea" +seed = 42 +setup_dir = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/experiments/mskc_test_2" +sparse = false + +[train_data] +[[train_data.data_sources]] +[train_data.data_sources.datafile] +filename = "/nrs/funke/hirschp/mskcc_emb3" +group = "volumes/raw_nested" +[train_data.data_sources.roi] +offset = [ 50, 0, 0, 0,] +shape = [ 10, 205, 512, 512,] + + +[model] +train_input_shape = [ 7, 40, 148, 148,] +# train_input_shape = [ 7, 40, 260, 260,] +predict_input_shape = [ 7, 80, 260, 260,] +unet_style = "split" +num_fmaps = [12, 12] +# fmap_inc_factors = [4, 4, 8] +fmap_inc_factors = 4 +downsample_factors = [[ 1, 2, 2,], + [ 1, 2, 2,], + [ 2, 2, 2,],] +# kernel_size_down = [[ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],],] +kernel_size_down = [[ [3, 3, 3, 3], [3, 3, 3, 3],], + [ [3, 3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],],] +# kernel_size_down = [[ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3],], +# [ [3, 3, 3], [3, 3, 3, 3],], +# [ [3, 3, 3, 3], [3, 3, 3, 3],],] +kernel_size_up = [[ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],], + [ [3, 3, 3], [3, 3, 3],],] +# constant_upsample = true +# upsampling = "pixel_shuffle" +# upsampling = "trilinear" +upsampling = "sep_transposed_conv" +average_vectors = false +# nms_window_shape = [ 3, 11, 11,] +nms_window_shape = [ 3, 9, 9,] +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/mknet_celegans.py" +cell_indicator_weighted = 0.01 +cell_indicator_cutoff = 0.01 +# cell_indicator_weighted = true +latent_temp_conv = false +train_only_cell_indicator = false + +[train] +# val_log_step = 25 +# radius for binary map -> *2 (in world units) +# in which to draw parent vectors (not used if use_radius) +parent_radius = [ 0.1, 8.0, 8.0, 8.0,] +# upper bound for dist cell moved between two frames (needed for context) +move_radius = 25 +# sigma for Gauss -> ~*4 (5 in z -> in 3 slices) (not used if use_radius) +rasterize_radius = [ 0.1, 5.0, 3.0, 3.0,] +cache_size = 1 +parent_vectors_loss_transition_offset = 20000 +parent_vectors_loss_transition_factor = 0.001 +#use_radius = true +path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/train_val_celegans_torch.py" +max_iterations = 11 +checkpoint_stride = 10 +snapshot_stride = 5 +profiling_stride = 10 +use_auto_mixed_precision = true +use_swa = true +swa_every_it = false +swa_start_it = 49999 +swa_freq_it = 1000 +use_grad_norm = true +[train.normalization] +type = "minmax" +perc_min = "perc0_01" +perc_max = "perc99_99" +norm_bounds = [ 2000, 7500,] + + +[train.use_radius] +30 = 20 +70 = 15 +100 = 13 +130 = 11 +180 = 10 +270 = 8 +9999 = 7 + +[optimizerTorch] +optimizer = "Adam" +# optimizer = "SGD" + + +[train.job] +num_workers = 1 +queue = "gpu_tesla" + +[train.augment] +divisions = true +reject_empty_prob = 0.9 +point_balance_radius = 75 + +[optimizerTorch.kwargs] +lr = 5e-5 +betas = [0.95, 0.999] +eps = 1e-8 +amsgrad = false +# weight_decay = 1e-4 +# momentum = 0.9 +# nesterov = true + +[train_data.roi] +offset = [ 50, 0, 0, 0,] +shape = [ 10, 205, 512, 512,] + +[train.augment.elastic] +control_point_spacing = [ 5, 25, 25,] +# control_point_spacing = [ 3, 15, 15,] +jitter_sigma = [ 1, 1, 1,] +rotation_min = -45 +rotation_max = 45 +rotation_3d = false +subsample = 4 +use_fast_points_transform = true + +[train.augment.zoom] +factor_min = 0.75 +factor_max = 1.5 +spatial_dims = 2 + +[train.augment.shift] +prob_slip = 0.2 +prob_shift = 0.2 +sigma = [ 0, 4, 4, 4,] + +[train.augment.intensity] +scale = [ 0.9, 1.1,] +shift = [ -0.001, 0.001,] + +[train.augment.simple] +mirror = [ 2, 3,] +transpose = [ 2, 3,] + +# # check snapshots per dataset for good value! +# # maybe just use speckle (multiplicative gaussian noise) +# # instead of this (additive gaussian noise) +# [train.augment.noise_gaussian] +# var = [ 0.001,] + +[train.augment.noise_saltpepper] +amount = [ 0.0001,] + +[train.augment.noise_speckle] +var = [ 0.05,] + +[train.augment.histogram] +range_low = 0.1 +range_high = 1.0 diff --git a/linajea/run_scripts/config_example_single_sample_val.toml b/linajea/run_scripts/config_example_single_sample_val.toml new file mode 100644 index 0000000..aac3d4f --- /dev/null +++ b/linajea/run_scripts/config_example_single_sample_val.toml @@ -0,0 +1,51 @@ +model = "config_example_single_sample_model.toml" +predict = "config_example_single_sample_predict.toml" + +[general] +logging = 20 +db_host = "mongodb://linajeaAdmin:FeOOHnH2O@funke-mongodb4/admin?replicaSet=rsLinajea" +seed = 42 +setup_dir = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/experiments/mskc_test_1" +sparse = false + + + +[extract] +block_size = [ 5, 512, 512, 512,] + +[solve] +from_scratch = true +check_node_close_to_roi = false +parameters = "test.toml" + + +[evaluate] +from_scratch = false + + +[inference_data] +checkpoint = 10 +cell_score_threshold = 0.2 +[inference_data.data_source] +gt_db_name = "linajea_celegans_emb_fixed" +# voxel_size = [ 1, 5, 1, 1,] +db_name = "linajea_celegans_20220624_134111b" +[inference_data.data_source.datafile] +filename = "/nrs/funke/hirschp/mskcc_emb" +group = "volumes/raw_nested" +[inference_data.data_source.roi] +offset = [ 50, 0, 0, 0,] +shape = [ 10, 205, 512, 512,] + +[extract.edge_move_threshold] +50 = 45 +1000 = 35 + +[extract.job] +num_workers = 1 +queue = "local" + +[evaluate.parameters] +matching_threshold = 15 +sparse = false + diff --git a/linajea/run_scripts/run.ipynb b/linajea/run_scripts/run.ipynb new file mode 100644 index 0000000..b346e8b --- /dev/null +++ b/linajea/run_scripts/run.ipynb @@ -0,0 +1,1990 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0754861b", + "metadata": {}, + "source": [ + "Linajea Tracking Example\n", + "=====================\n", + "\n", + "\n", + "This example show all steps necessary to generate the final tracks, from training the network to finding the optimal hyperparameters on the validation data to computing the tracks on the test data.\n", + "\n", + "- train network\n", + "- predict on validation data\n", + "- grid search hyperparameters for ILP\n", + " - solve once per set of parameters\n", + " - evaluate once per set of parameters\n", + " - select set with fewest errors\n", + "- predict on test data\n", + "- solve on test data with optimal parameters\n", + "- evaluate on test data" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e5c7284e", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "import os\n", + "import sys\n", + "import time\n", + "import types\n", + "\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "from linajea.config import (dump_config,\n", + " maybe_fix_config_paths_to_machine_and_load,\n", + " TrackingConfig)\n", + "from linajea.utils import (getNextInferenceData,\n", + " print_time)\n", + "import linajea.evaluation\n", + "from linajea.process_blockwise import (extract_edges_blockwise,\n", + " predict_blockwise,\n", + " solve_blockwise)\n", + "from linajea.training import train" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "dffaf22c", + "metadata": {}, + "outputs": [], + "source": [ + "logging.basicConfig(\n", + " level=logging.INFO,\n", + " format='%(asctime)s %(name)s %(levelname)-8s %(message)s')" + ] + }, + { + "cell_type": "markdown", + "id": "428b2706", + "metadata": {}, + "source": [ + "Configuration\n", + "--------------------\n", + "\n", + "All parameters to control the pipeline (e.g. model architecture, data augmentation, training parameters, ILP hyperparameters) are contained in a configuration file (in the TOML format https://toml.io)\n", + "\n", + "You can modify the `config_file` variable to point to the config file you would like to use. Make sure that the file paths contained in it point to the correct destination, for instance that they are adapted to your directory structure." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a28ca24d", + "metadata": {}, + "outputs": [], + "source": [ + "config_file = \"config_example.toml\"\n", + "config = maybe_fix_config_paths_to_machine_and_load(config_file)\n", + "config = TrackingConfig(**config)\n", + "#config = TrackingConfig.from_file(config)file\n", + "os.makedirs(config.general.setup_dir, exist_ok=True)" + ] + }, + { + "cell_type": "markdown", + "id": "79e4a402", + "metadata": {}, + "source": [ + "Training\n", + "------------\n", + "\n", + "To start training simply pass the configuration object to the train function. Make sure that the training data and parameters such as the number of iterations/setps are set correctly.\n", + "\n", + "To train until convergence will take from several hours to multiple days." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "f2ae694b", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-24 06:01:51,511 linajea.training.torch_model INFO initializing model..\n", + "2022-06-24 06:01:54,366 linajea.training.torch_model INFO getting train/test output shape by running model twice\n", + "2022-06-24 06:01:58,372 linajea.training.torch_model INFO test done\n", + "2022-06-24 06:01:58,813 linajea.training.torch_model INFO train done\n", + "2022-06-24 06:01:58,819 linajea.training.train INFO Model: UnetModelWrapper(\n", + " (unet_cell_ind): UNet(\n", + " (l_conv): ModuleList(\n", + " (0): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv4d(\n", + " (conv3d_layers): ModuleList(\n", + " (0): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (2): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " )\n", + " )\n", + " (1): ReLU()\n", + " (2): Conv4d(\n", + " (conv3d_layers): ModuleList(\n", + " (0): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (2): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " )\n", + " )\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " (1): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv4d(\n", + " (conv3d_layers): ModuleList(\n", + " (0): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (2): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " )\n", + " )\n", + " (1): ReLU()\n", + " (2): Conv3d(48, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " (2): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(48, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): ReLU()\n", + " (2): Conv3d(192, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " (3): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(192, 768, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): ReLU()\n", + " (2): Conv3d(768, 768, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " )\n", + " (l_down): ModuleList(\n", + " (0): Downsample(\n", + " (down): MaxPool3d(kernel_size=[1, 2, 2], stride=[1, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", + " )\n", + " (1): Downsample(\n", + " (down): MaxPool3d(kernel_size=[1, 2, 2], stride=[1, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", + " )\n", + " (2): Downsample(\n", + " (down): MaxPool3d(kernel_size=[2, 2, 2], stride=[2, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", + " )\n", + " )\n", + " (r_up): ModuleList(\n", + " (0): ModuleList(\n", + " (0): Upsample(\n", + " (up): Sequential(\n", + " (0): ConvTranspose3d(48, 48, kernel_size=(1, 2, 2), stride=(1, 2, 2), groups=48)\n", + " (1): Conv3d(48, 12, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", + " )\n", + " (activation): ReLU()\n", + " )\n", + " (1): Upsample(\n", + " (up): Sequential(\n", + " (0): ConvTranspose3d(192, 192, kernel_size=(1, 2, 2), stride=(1, 2, 2), groups=192)\n", + " (1): Conv3d(192, 48, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", + " )\n", + " (activation): ReLU()\n", + " )\n", + " (2): Upsample(\n", + " (up): Sequential(\n", + " (0): ConvTranspose3d(768, 768, kernel_size=(2, 2, 2), stride=(2, 2, 2), groups=768)\n", + " (1): Conv3d(768, 192, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", + " )\n", + " (activation): ReLU()\n", + " )\n", + " )\n", + " )\n", + " (r_conv): ModuleList(\n", + " (0): ModuleList(\n", + " (0): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(24, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): ReLU()\n", + " (2): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " (1): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(96, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): ReLU()\n", + " (2): Conv3d(48, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " (2): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(384, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): ReLU()\n", + " (2): Conv3d(192, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " )\n", + " )\n", + " )\n", + " (unet_par_vec): UNet(\n", + " (l_conv): ModuleList(\n", + " (0): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv4d(\n", + " (conv3d_layers): ModuleList(\n", + " (0): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (2): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " )\n", + " )\n", + " (1): ReLU()\n", + " (2): Conv4d(\n", + " (conv3d_layers): ModuleList(\n", + " (0): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (2): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " )\n", + " )\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " (1): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv4d(\n", + " (conv3d_layers): ModuleList(\n", + " (0): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (2): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " )\n", + " )\n", + " (1): ReLU()\n", + " (2): Conv3d(48, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " (2): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(48, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): ReLU()\n", + " (2): Conv3d(192, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " (3): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(192, 768, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): ReLU()\n", + " (2): Conv3d(768, 768, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " )\n", + " (l_down): ModuleList(\n", + " (0): Downsample(\n", + " (down): MaxPool3d(kernel_size=[1, 2, 2], stride=[1, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", + " )\n", + " (1): Downsample(\n", + " (down): MaxPool3d(kernel_size=[1, 2, 2], stride=[1, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", + " )\n", + " (2): Downsample(\n", + " (down): MaxPool3d(kernel_size=[2, 2, 2], stride=[2, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", + " )\n", + " )\n", + " (r_up): ModuleList(\n", + " (0): ModuleList(\n", + " (0): Upsample(\n", + " (up): Sequential(\n", + " (0): ConvTranspose3d(48, 48, kernel_size=(1, 2, 2), stride=(1, 2, 2), groups=48)\n", + " (1): Conv3d(48, 12, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", + " )\n", + " (activation): ReLU()\n", + " )\n", + " (1): Upsample(\n", + " (up): Sequential(\n", + " (0): ConvTranspose3d(192, 192, kernel_size=(1, 2, 2), stride=(1, 2, 2), groups=192)\n", + " (1): Conv3d(192, 48, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", + " )\n", + " (activation): ReLU()\n", + " )\n", + " (2): Upsample(\n", + " (up): Sequential(\n", + " (0): ConvTranspose3d(768, 768, kernel_size=(2, 2, 2), stride=(2, 2, 2), groups=768)\n", + " (1): Conv3d(768, 192, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", + " )\n", + " (activation): ReLU()\n", + " )\n", + " )\n", + " )\n", + " (r_conv): ModuleList(\n", + " (0): ModuleList(\n", + " (0): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(24, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): ReLU()\n", + " (2): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " (1): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(96, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): ReLU()\n", + " (2): Conv3d(48, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " (2): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(384, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (1): ReLU()\n", + " (2): Conv3d(192, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", + " (3): ReLU()\n", + " )\n", + " )\n", + " )\n", + " )\n", + " )\n", + " (cell_indicator_batched): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(12, 1, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", + " (1): Sigmoid()\n", + " )\n", + " )\n", + " (parent_vectors_batched): ConvPass(\n", + " (layers): ModuleList(\n", + " (0): Conv3d(12, 3, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", + " )\n", + " )\n", + " (nms): MaxPool3d(kernel_size=[3, 9, 9], stride=1, padding=0, dilation=1, ceil_mode=False)\n", + ")\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-24 06:01:58,821 linajea.training.train INFO Center size: (2, 30, 52, 52)\n", + "2022-06-24 06:01:58,821 linajea.training.train INFO Output size 1: (1, 40, 60, 60)\n", + "2022-06-24 06:01:58,821 linajea.training.train INFO Voxel size: (1, 5, 1, 1)\n", + "2022-06-24 06:01:58,824 linajea.training.train INFO REQUEST: \n", + "\tRAW: ROI: [0:7, 0:200, 0:148, 0:148] (7, 200, 148, 148), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tCELL_INDICATOR: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tCELL_CENTER: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tANCHOR: ROI: [3:4, 85:115, 48:100, 48:100] (1, 30, 52, 52), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tRAW_CROPPED: ROI: [3:4, 85:115, 48:100, 48:100] (1, 30, 52, 52), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tMAXIMA: ROI: [3:4, 85:115, 48:100, 48:100] (1, 30, 52, 52), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tPARENT_VECTORS: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tCELL_MASK: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tTRACKS: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), dtype: , directed: None, placeholder: False\n", + "\tCENTER_TRACKS: ROI: [2:4, 85:115, 48:100, 48:100] (2, 30, 52, 52), dtype: , directed: None, placeholder: False\n", + "\n", + "2022-06-24 06:01:58,826 linajea.training.train INFO Snapshot request: \n", + "\tRAW: ROI: [0:7, 0:200, 0:148, 0:148] (7, 200, 148, 148), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tPRED_CELL_INDICATOR: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tGRAD_CELL_INDICATOR: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tRAW_CROPPED: ROI: [3:4, 85:115, 48:100, 48:100] (1, 30, 52, 52), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tMAXIMA: ROI: [3:4, 85:115, 48:100, 48:100] (1, 30, 52, 52), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tPRED_PARENT_VECTORS: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\tGRAD_PARENT_VECTORS: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", + "\n", + "2022-06-24 06:01:58,827 linajea.training.train INFO loading data /nrs/funke/hirschp/mskcc_emb1 (val: False)\n", + "2022-06-24 06:01:58,843 linajea.training.train WARNING Cannot find divisions_file in data_config, falling back to using tracks_file(usually ok unless they are not included and there is a separate file containing the divisions)\n", + "2022-06-24 06:01:58,844 linajea.training.train INFO creating source: /nrs/funke/hirschp/mskcc_emb1/emb1.zarr (volumes/raw_nested, /nrs/funke/hirschp/mskcc_emb1/mskcc_emb1_tracks.txt, /nrs/funke/hirschp/mskcc_emb1/mskcc_emb1_tracks_daughters.txt), divisions?: True\n", + "2022-06-24 06:01:58,845 linajea.training.train INFO limiting to roi: [0:50, 0:205, 0:512, 0:512] (50, 205, 512, 512)\n", + "2022-06-24 06:01:58,845 linajea.training.train INFO minmax normalization 2000 7500\n", + "2022-06-24 06:01:58,846 linajea.training.train INFO loading data /nrs/funke/hirschp/mskcc_emb (val: True)\n", + "2022-06-24 06:01:58,847 linajea.training.train WARNING Cannot find divisions_file in data_config, falling back to using tracks_file(usually ok unless they are not included and there is a separate file containing the divisions)\n", + "2022-06-24 06:01:58,848 linajea.training.train INFO creating source: /nrs/funke/hirschp/mskcc_emb/emb.zarr (volumes/raw_nested, /nrs/funke/hirschp/mskcc_emb/mskcc_emb_tracks.txt, /nrs/funke/hirschp/mskcc_emb/mskcc_emb_tracks_daughters.txt), divisions?: True\n", + "2022-06-24 06:01:58,848 linajea.training.train INFO limiting to roi: [0:50, 0:205, 0:512, 0:512] (50, 205, 512, 512)\n", + "2022-06-24 06:01:58,849 linajea.training.train INFO minmax normalization 2000 7500\n", + "2022-06-24 06:01:58,850 gunpowder.nodes.zoom_augment INFO using zoom/scale augment 0.75 1.5 {RAW: 1} {}\n", + "2022-06-24 06:01:58,850 gunpowder.nodes.noise_augment INFO using noise augment speckle {'var': [0.05]}\n", + "2022-06-24 06:01:58,850 gunpowder.nodes.noise_augment INFO using noise augment s&p {'amount': [0.0001]}\n", + "2022-06-24 06:02:01,204 gunpowder.nodes.random_location INFO requesting all CENTER_TRACKS points...\n", + "2022-06-24 06:02:02,503 gunpowder.nodes.random_location INFO requesting all CENTER_TRACKS points...\n", + "2022-06-24 06:02:02,517 gunpowder.nodes.histogram_augment INFO setting up histogram augment\n", + "2022-06-24 06:02:05,273 gunpowder.nodes.random_location INFO requesting all CENTER_TRACKS points...\n", + "2022-06-24 06:02:06,722 gunpowder.nodes.random_location INFO requesting all CENTER_TRACKS points...\n", + "2022-06-24 06:02:06,738 linajea.training.train INFO Starting training...\n", + "2022-06-24 06:02:09,052 gunpowder.torch.nodes.train INFO using auto mixed precision\n", + "2022-06-24 06:02:09,071 gunpowder.torch.nodes.train INFO Resuming training from iteration 10\n", + "2022-06-24 06:02:09,073 gunpowder.torch.nodes.train INFO Loading /groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/experiments/mskc_test_1/train_net_checkpoint_10\n", + "2022-06-24 06:02:10,774 gunpowder.torch.nodes.train WARNING no scaler state dict in checkpoint!\n", + "2022-06-24 06:02:10,776 gunpowder.torch.nodes.train INFO Using device cuda\n", + " 0%| | 0/1 [00:04\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-24 13:44:40,123 linajea.evaluation.match INFO Done matching, found 234 matches and 0 edge fps\n", + "2022-06-24 13:44:40,123 linajea.evaluation.evaluate INFO Done matching. Evaluating\n", + "2022-06-24 13:44:40,145 linajea.evaluation.evaluator INFO Getting AEFTL and ERL\n", + "2022-06-24 13:44:40,155 linajea.evaluation.evaluator INFO Getting perfect segments\n", + "2022-06-24 13:44:40,165 linajea.evaluation.evaluator INFO track range 59 50\n", + "2022-06-24 13:44:40,167 linajea.evaluation.evaluator INFO error free tracks: 28/30 1.0\n", + "2022-06-24 13:44:40,177 linajea.evaluation.evaluate_setup INFO Done evaluating results for 6. Saving results to mongo.\n", + "2022-06-24 13:44:40,178 linajea.evaluation.evaluate_setup INFO Result summary: {'gt_tracks': 24, 'rec_tracks': 26, 'gt_matched_tracks': 24, 'rec_matched_tracks': 24, 'gt_edges': 234, 'rec_edges': 252, 'matched_edges': 234, 'gt_divisions': 4, 'rec_divisions': 4, 'fp_edges': 18, 'fn_edges': 0, 'identity_switches': 0, 'fp_divisions': 0, 'iso_fp_division': 0, 'fn_divisions': 0, 'iso_fn_division': 0, 'fn_divs_no_connections': 0, 'fn_divs_unconnected_child': 0, 'fn_divs_unconnected_parent': 0, 'precision': 0.9285714285714286, 'recall': 1.0, 'f_score': 0.962962962962963, 'aeftl': 9.75, 'erl': 10.247863247863247, 'correct_segments': {'1': (234, 234), '2': (206, 206), '3': (180, 180), '4': (154, 154), '5': (128, 128), '6': (102, 102), '7': (76, 76), '8': (50, 50), '9': (24, 24), '10': (0, 0), '11': (0, 0), '12': (0, 0), '13': (0, 0), '14': (0, 0), '15': (0, 0), '16': (0, 0), '17': (0, 0), '18': (0, 0), '19': (0, 0), '20': (0, 0), '21': (0, 0), '22': (0, 0), '23': (0, 0), '24': (0, 0), '25': (0, 0), '26': (0, 0), '27': (0, 0), '28': (0, 0), '29': (0, 0), '30': (0, 0), '31': (0, 0), '32': (0, 0), '33': (0, 0), '34': (0, 0), '35': (0, 0), '36': (0, 0), '37': (0, 0), '38': (0, 0), '39': (0, 0), '40': (0, 0), '41': (0, 0), '42': (0, 0), '43': (0, 0), '44': (0, 0), '45': (0, 0), '46': (0, 0), '47': (0, 0), '48': (0, 0), '49': (0, 0), '50': (0, 0), '51': (0, 0), '52': (0, 0), '53': (0, 0), '54': (0, 0), '55': (0, 0), '56': (0, 0), '57': (0, 0), '58': (0, 0), '59': (0, 0), '60': (0, 0), '61': (0, 0), '62': (0, 0), '63': (0, 0), '64': (0, 0), '65': (0, 0), '66': (0, 0), '67': (0, 0), '68': (0, 0), '69': (0, 0), '70': (0, 0), '71': (0, 0), '72': (0, 0), '73': (0, 0), '74': (0, 0), '75': (0, 0), '76': (0, 0), '77': (0, 0), '78': (0, 0), '79': (0, 0), '80': (0, 0), '81': (0, 0), '82': (0, 0), '83': (0, 0), '84': (0, 0), '85': (0, 0), '86': (0, 0), '87': (0, 0), '88': (0, 0), '89': (0, 0), '90': (0, 0), '91': (0, 0), '92': (0, 0), '93': (0, 0), '94': (0, 0), '95': (0, 0), '96': (0, 0), '97': (0, 0), '98': (0, 0), '99': (0, 0), '100': (0, 0), '101': (0, 0), '102': (0, 0), '103': (0, 0), '104': (0, 0), '105': (0, 0), '106': (0, 0), '107': (0, 0), '108': (0, 0), '109': (0, 0), '110': (0, 0), '111': (0, 0), '112': (0, 0), '113': (0, 0), '114': (0, 0), '115': (0, 0), '116': (0, 0), '117': (0, 0), '118': (0, 0), '119': (0, 0), '120': (0, 0), '121': (0, 0), '122': (0, 0), '123': (0, 0), '124': (0, 0), '125': (0, 0), '126': (0, 0), '127': (0, 0), '128': (0, 0), '129': (0, 0), '130': (0, 0), '131': (0, 0), '132': (0, 0), '133': (0, 0), '134': (0, 0), '135': (0, 0), '136': (0, 0), '137': (0, 0), '138': (0, 0), '139': (0, 0), '140': (0, 0), '141': (0, 0), '142': (0, 0), '143': (0, 0), '144': (0, 0), '145': (0, 0), '146': (0, 0), '147': (0, 0), '148': (0, 0), '149': (0, 0), '150': (0, 0), '151': (0, 0), '152': (0, 0), '153': (0, 0), '154': (0, 0), '155': (0, 0), '156': (0, 0), '157': (0, 0), '158': (0, 0), '159': (0, 0), '160': (0, 0), '161': (0, 0), '162': (0, 0), '163': (0, 0), '164': (0, 0), '165': (0, 0), '166': (0, 0), '167': (0, 0), '168': (0, 0), '169': (0, 0), '170': (0, 0), '171': (0, 0), '172': (0, 0), '173': (0, 0), '174': (0, 0), '175': (0, 0), '176': (0, 0), '177': (0, 0), '178': (0, 0), '179': (0, 0), '180': (0, 0), '181': (0, 0), '182': (0, 0), '183': (0, 0), '184': (0, 0), '185': (0, 0), '186': (0, 0), '187': (0, 0), '188': (0, 0), '189': (0, 0), '190': (0, 0), '191': (0, 0), '192': (0, 0), '193': (0, 0), '194': (0, 0), '195': (0, 0), '196': (0, 0), '197': (0, 0), '198': (0, 0), '199': (0, 0), '200': (0, 0), '201': (0, 0), '202': (0, 0), '203': (0, 0), '204': (0, 0), '205': (0, 0), '206': (0, 0), '207': (0, 0), '208': (0, 0), '209': (0, 0), '210': (0, 0), '211': (0, 0), '212': (0, 0), '213': (0, 0), '214': (0, 0), '215': (0, 0), '216': (0, 0), '217': (0, 0), '218': (0, 0), '219': (0, 0), '220': (0, 0), '221': (0, 0), '222': (0, 0), '223': (0, 0), '224': (0, 0), '225': (0, 0), '226': (0, 0), '227': (0, 0), '228': (0, 0), '229': (0, 0), '230': (0, 0), '231': (0, 0), '232': (0, 0), '233': (0, 0), '234': (0, 0), '235': (0, 0), '236': (0, 0), '237': (0, 0), '238': (0, 0), '239': (0, 0), '240': (0, 0), '241': (0, 0), '242': (0, 0), '243': (0, 0), '244': (0, 0), '245': (0, 0), '246': (0, 0), '247': (0, 0), '248': (0, 0), '249': (0, 0), '250': (0, 0), '251': (0, 0), '252': (0, 0), '253': (0, 0), '254': (0, 0), '255': (0, 0), '256': (0, 0), '257': (0, 0), '258': (0, 0), '259': (0, 0), '260': (0, 0), '261': (0, 0), '262': (0, 0), '263': (0, 0), '264': (0, 0), '265': (0, 0), '266': (0, 0), '267': (0, 0), '268': (0, 0), '269': (0, 0), '270': (0, 0)}, 'validation_score': None, 'fn_div_count_unconnected_parent': False, 'num_error_free_tracks': 28, 'num_rec_cells_last_frame': 30, 'num_gt_cells_last_frame': 28, 'node_recall': 1.0, 'edge_recall': 0.9829059829059829}\n", + "2022-06-24 13:44:40,211 linajea.utils.candidate_database INFO writing scores for {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 9, 'weight_division': -8, 'division_constant': 2.5, 'weight_child': 2.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'feature_func': 'noop', 'val': True} to {'param_id': 6, 'matching_threshold': 15, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'validation_score': False, 'window_size': 50, 'filter_short_tracklets_len': -1, 'ignore_one_off_div_errors': False, 'fn_div_count_unconnected_parent': True, 'sparse': False}\n", + "2022-06-24 13:44:40,215 linajea.evaluation.evaluate_setup INFO roi DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512)) DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512))\n", + "2022-06-24 13:44:40,235 linajea.utils.candidate_database INFO Querying ID for parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 12, 'weight_division': -11, 'division_constant': 2.5, 'weight_child': 2.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}}\n", + "2022-06-24 13:44:40,237 linajea.utils.candidate_database INFO Parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 12, 'weight_division': -11, 'division_constant': 2.5, 'weight_child': 2.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}} already in collection with id 52\n", + "2022-06-24 13:44:40,253 linajea.evaluation.evaluate_setup INFO Evaluating mskcc_emb in [50:60, 0:205, 0:512, 0:512] (10, 205, 512, 512)\n", + "2022-06-24 13:44:40,275 linajea.evaluation.evaluate_setup INFO Reading cells and edges in db linajea_celegans_20220624_134111 with parameter_id 52\n", + "2022-06-24 13:44:40,282 linajea.evaluation.evaluate_setup INFO Read 0 cells and 0 edges in 0.006875514984130859 seconds\n", + "2022-06-24 13:44:40,282 linajea.evaluation.evaluate_setup WARNING No selected edges for parameters_id 52. Skipping\n", + "2022-06-24 13:44:40,284 linajea.evaluation.evaluate_setup INFO roi DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512)) DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512))\n", + "2022-06-24 13:44:40,305 linajea.utils.candidate_database INFO Querying ID for parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 6, 'weight_division': -8, 'division_constant': 2.5, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}}\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-24 13:44:40,306 linajea.utils.candidate_database INFO Parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 6, 'weight_division': -8, 'division_constant': 2.5, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}} already in collection with id 17\n", + "2022-06-24 13:44:40,324 linajea.evaluation.evaluate_setup INFO Evaluating mskcc_emb in [50:60, 0:205, 0:512, 0:512] (10, 205, 512, 512)\n", + "2022-06-24 13:44:40,344 linajea.evaluation.evaluate_setup INFO Reading cells and edges in db linajea_celegans_20220624_134111 with parameter_id 17\n", + "2022-06-24 13:44:40,355 linajea.evaluation.evaluate_setup INFO Read 305 cells and 279 edges in 0.009444236755371094 seconds\n", + "2022-06-24 13:44:40,364 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,366 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,366 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,367 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,368 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 18\n", + "2022-06-24 13:44:40,368 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,369 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 50, track len: 1\n", + "2022-06-24 13:44:40,369 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 18\n", + "2022-06-24 13:44:40,369 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,369 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,370 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,370 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,370 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,370 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,371 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,371 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,371 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 11\n", + "2022-06-24 13:44:40,372 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,372 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,372 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,372 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,373 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,373 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 11\n", + "2022-06-24 13:44:40,373 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,373 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,374 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,374 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,399 linajea.evaluation.evaluate_setup INFO Reading ground truth cells and edges in db linajea_celegans_emb_fixed\n", + "2022-06-24 13:44:40,408 linajea.evaluation.evaluate_setup INFO Read 282 cells and 258 edges in 0.00831151008605957 seconds\n", + "2022-06-24 13:44:40,411 linajea.evaluation.evaluate_setup INFO Matching edges for parameters with id 17\n", + "2022-06-24 13:44:40,411 linajea.evaluation.evaluate INFO Checking validity of reconstruction\n", + "2022-06-24 13:44:40,412 linajea.evaluation.evaluate INFO Matching GT edges to REC edges...\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "False\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-24 13:44:40,562 linajea.evaluation.match INFO Done matching, found 233 matches and 0 edge fps\n", + "2022-06-24 13:44:40,562 linajea.evaluation.evaluate INFO Done matching. Evaluating\n", + "2022-06-24 13:44:40,583 linajea.evaluation.evaluator INFO Getting AEFTL and ERL\n", + "2022-06-24 13:44:40,593 linajea.evaluation.evaluator INFO Getting perfect segments\n", + "2022-06-24 13:44:40,604 linajea.evaluation.evaluator INFO track range 59 50\n", + "2022-06-24 13:44:40,607 linajea.evaluation.evaluator INFO error free tracks: 27/30 0.9642857142857143\n", + "2022-06-24 13:44:40,616 linajea.evaluation.evaluate_setup INFO Done evaluating results for 17. Saving results to mongo.\n", + "2022-06-24 13:44:40,617 linajea.evaluation.evaluate_setup INFO Result summary: {'gt_tracks': 24, 'rec_tracks': 27, 'gt_matched_tracks': 24, 'rec_matched_tracks': 24, 'gt_edges': 234, 'rec_edges': 252, 'matched_edges': 233, 'gt_divisions': 4, 'rec_divisions': 4, 'fp_edges': 19, 'fn_edges': 0, 'identity_switches': 0, 'fp_divisions': 0, 'iso_fp_division': 0, 'fn_divisions': 0, 'iso_fn_division': 1, 'fn_divs_no_connections': 0, 'fn_divs_unconnected_child': 0, 'fn_divs_unconnected_parent': 1, 'precision': 0.9246031746031746, 'recall': 0.9957264957264957, 'f_score': 0.9588477366255145, 'aeftl': 9.708333333333334, 'erl': 10.106837606837606, 'correct_segments': {'1': (233, 234), '2': (205, 206), '3': (179, 180), '4': (153, 154), '5': (127, 128), '6': (101, 102), '7': (75, 76), '8': (49, 50), '9': (23, 24), '10': (0, 0), '11': (0, 0), '12': (0, 0), '13': (0, 0), '14': (0, 0), '15': (0, 0), '16': (0, 0), '17': (0, 0), '18': (0, 0), '19': (0, 0), '20': (0, 0), '21': (0, 0), '22': (0, 0), '23': (0, 0), '24': (0, 0), '25': (0, 0), '26': (0, 0), '27': (0, 0), '28': (0, 0), '29': (0, 0), '30': (0, 0), '31': (0, 0), '32': (0, 0), '33': (0, 0), '34': (0, 0), '35': (0, 0), '36': (0, 0), '37': (0, 0), '38': (0, 0), '39': (0, 0), '40': (0, 0), '41': (0, 0), '42': (0, 0), '43': (0, 0), '44': (0, 0), '45': (0, 0), '46': (0, 0), '47': (0, 0), '48': (0, 0), '49': (0, 0), '50': (0, 0), '51': (0, 0), '52': (0, 0), '53': (0, 0), '54': (0, 0), '55': (0, 0), '56': (0, 0), '57': (0, 0), '58': (0, 0), '59': (0, 0), '60': (0, 0), '61': (0, 0), '62': (0, 0), '63': (0, 0), '64': (0, 0), '65': (0, 0), '66': (0, 0), '67': (0, 0), '68': (0, 0), '69': (0, 0), '70': (0, 0), '71': (0, 0), '72': (0, 0), '73': (0, 0), '74': (0, 0), '75': (0, 0), '76': (0, 0), '77': (0, 0), '78': (0, 0), '79': (0, 0), '80': (0, 0), '81': (0, 0), '82': (0, 0), '83': (0, 0), '84': (0, 0), '85': (0, 0), '86': (0, 0), '87': (0, 0), '88': (0, 0), '89': (0, 0), '90': (0, 0), '91': (0, 0), '92': (0, 0), '93': (0, 0), '94': (0, 0), '95': (0, 0), '96': (0, 0), '97': (0, 0), '98': (0, 0), '99': (0, 0), '100': (0, 0), '101': (0, 0), '102': (0, 0), '103': (0, 0), '104': (0, 0), '105': (0, 0), '106': (0, 0), '107': (0, 0), '108': (0, 0), '109': (0, 0), '110': (0, 0), '111': (0, 0), '112': (0, 0), '113': (0, 0), '114': (0, 0), '115': (0, 0), '116': (0, 0), '117': (0, 0), '118': (0, 0), '119': (0, 0), '120': (0, 0), '121': (0, 0), '122': (0, 0), '123': (0, 0), '124': (0, 0), '125': (0, 0), '126': (0, 0), '127': (0, 0), '128': (0, 0), '129': (0, 0), '130': (0, 0), '131': (0, 0), '132': (0, 0), '133': (0, 0), '134': (0, 0), '135': (0, 0), '136': (0, 0), '137': (0, 0), '138': (0, 0), '139': (0, 0), '140': (0, 0), '141': (0, 0), '142': (0, 0), '143': (0, 0), '144': (0, 0), '145': (0, 0), '146': (0, 0), '147': (0, 0), '148': (0, 0), '149': (0, 0), '150': (0, 0), '151': (0, 0), '152': (0, 0), '153': (0, 0), '154': (0, 0), '155': (0, 0), '156': (0, 0), '157': (0, 0), '158': (0, 0), '159': (0, 0), '160': (0, 0), '161': (0, 0), '162': (0, 0), '163': (0, 0), '164': (0, 0), '165': (0, 0), '166': (0, 0), '167': (0, 0), '168': (0, 0), '169': (0, 0), '170': (0, 0), '171': (0, 0), '172': (0, 0), '173': (0, 0), '174': (0, 0), '175': (0, 0), '176': (0, 0), '177': (0, 0), '178': (0, 0), '179': (0, 0), '180': (0, 0), '181': (0, 0), '182': (0, 0), '183': (0, 0), '184': (0, 0), '185': (0, 0), '186': (0, 0), '187': (0, 0), '188': (0, 0), '189': (0, 0), '190': (0, 0), '191': (0, 0), '192': (0, 0), '193': (0, 0), '194': (0, 0), '195': (0, 0), '196': (0, 0), '197': (0, 0), '198': (0, 0), '199': (0, 0), '200': (0, 0), '201': (0, 0), '202': (0, 0), '203': (0, 0), '204': (0, 0), '205': (0, 0), '206': (0, 0), '207': (0, 0), '208': (0, 0), '209': (0, 0), '210': (0, 0), '211': (0, 0), '212': (0, 0), '213': (0, 0), '214': (0, 0), '215': (0, 0), '216': (0, 0), '217': (0, 0), '218': (0, 0), '219': (0, 0), '220': (0, 0), '221': (0, 0), '222': (0, 0), '223': (0, 0), '224': (0, 0), '225': (0, 0), '226': (0, 0), '227': (0, 0), '228': (0, 0), '229': (0, 0), '230': (0, 0), '231': (0, 0), '232': (0, 0), '233': (0, 0), '234': (0, 0), '235': (0, 0), '236': (0, 0), '237': (0, 0), '238': (0, 0), '239': (0, 0), '240': (0, 0), '241': (0, 0), '242': (0, 0), '243': (0, 0), '244': (0, 0), '245': (0, 0), '246': (0, 0), '247': (0, 0), '248': (0, 0), '249': (0, 0), '250': (0, 0), '251': (0, 0), '252': (0, 0), '253': (0, 0), '254': (0, 0), '255': (0, 0), '256': (0, 0), '257': (0, 0), '258': (0, 0), '259': (0, 0), '260': (0, 0), '261': (0, 0), '262': (0, 0), '263': (0, 0), '264': (0, 0), '265': (0, 0), '266': (0, 0), '267': (0, 0), '268': (0, 0), '269': (0, 0), '270': (0, 0)}, 'validation_score': None, 'fn_div_count_unconnected_parent': False, 'num_error_free_tracks': 27, 'num_rec_cells_last_frame': 30, 'num_gt_cells_last_frame': 28, 'node_recall': 1.0, 'edge_recall': 0.9829059829059829}\n", + "2022-06-24 13:44:40,648 linajea.utils.candidate_database INFO writing scores for {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 6, 'weight_division': -8, 'division_constant': 2.5, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'feature_func': 'noop', 'val': True} to {'param_id': 17, 'matching_threshold': 15, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'validation_score': False, 'window_size': 50, 'filter_short_tracklets_len': -1, 'ignore_one_off_div_errors': False, 'fn_div_count_unconnected_parent': True, 'sparse': False}\n", + "2022-06-24 13:44:40,652 linajea.evaluation.evaluate_setup INFO roi DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512)) DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512))\n", + "2022-06-24 13:44:40,674 linajea.utils.candidate_database INFO Querying ID for parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 9, 'weight_division': -11, 'division_constant': 6.0, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}}\n", + "2022-06-24 13:44:40,676 linajea.utils.candidate_database INFO Parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 9, 'weight_division': -11, 'division_constant': 6.0, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}} already in collection with id 43\n", + "2022-06-24 13:44:40,693 linajea.evaluation.evaluate_setup INFO Evaluating mskcc_emb in [50:60, 0:205, 0:512, 0:512] (10, 205, 512, 512)\n", + "2022-06-24 13:44:40,713 linajea.evaluation.evaluate_setup INFO Reading cells and edges in db linajea_celegans_20220624_134111 with parameter_id 43\n", + "2022-06-24 13:44:40,724 linajea.evaluation.evaluate_setup INFO Read 304 cells and 278 edges in 0.010981321334838867 seconds\n", + "2022-06-24 13:44:40,734 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,735 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,736 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-24 13:44:40,737 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,737 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 18\n", + "2022-06-24 13:44:40,738 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,738 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 18\n", + "2022-06-24 13:44:40,738 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,739 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,739 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,739 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,739 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,740 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,740 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,740 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,740 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 11\n", + "2022-06-24 13:44:40,741 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,741 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,741 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,741 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,742 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,742 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 11\n", + "2022-06-24 13:44:40,742 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,743 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,743 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,743 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", + "2022-06-24 13:44:40,768 linajea.evaluation.evaluate_setup INFO Reading ground truth cells and edges in db linajea_celegans_emb_fixed\n", + "2022-06-24 13:44:40,777 linajea.evaluation.evaluate_setup INFO Read 282 cells and 258 edges in 0.008458852767944336 seconds\n", + "2022-06-24 13:44:40,780 linajea.evaluation.evaluate_setup INFO Matching edges for parameters with id 43\n", + "2022-06-24 13:44:40,781 linajea.evaluation.evaluate INFO Checking validity of reconstruction\n", + "2022-06-24 13:44:40,781 linajea.evaluation.evaluate INFO Matching GT edges to REC edges...\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-24 13:44:40,931 linajea.evaluation.match INFO Done matching, found 234 matches and 0 edge fps\n", + "2022-06-24 13:44:40,931 linajea.evaluation.evaluate INFO Done matching. Evaluating\n", + "2022-06-24 13:44:40,953 linajea.evaluation.evaluator INFO Getting AEFTL and ERL\n", + "2022-06-24 13:44:40,963 linajea.evaluation.evaluator INFO Getting perfect segments\n", + "2022-06-24 13:44:40,973 linajea.evaluation.evaluator INFO track range 59 50\n", + "2022-06-24 13:44:40,976 linajea.evaluation.evaluator INFO error free tracks: 28/30 1.0\n", + "2022-06-24 13:44:40,985 linajea.evaluation.evaluate_setup INFO Done evaluating results for 43. Saving results to mongo.\n", + "2022-06-24 13:44:40,986 linajea.evaluation.evaluate_setup INFO Result summary: {'gt_tracks': 24, 'rec_tracks': 26, 'gt_matched_tracks': 24, 'rec_matched_tracks': 24, 'gt_edges': 234, 'rec_edges': 252, 'matched_edges': 234, 'gt_divisions': 4, 'rec_divisions': 4, 'fp_edges': 18, 'fn_edges': 0, 'identity_switches': 0, 'fp_divisions': 0, 'iso_fp_division': 0, 'fn_divisions': 0, 'iso_fn_division': 0, 'fn_divs_no_connections': 0, 'fn_divs_unconnected_child': 0, 'fn_divs_unconnected_parent': 0, 'precision': 0.9285714285714286, 'recall': 1.0, 'f_score': 0.962962962962963, 'aeftl': 9.75, 'erl': 10.247863247863247, 'correct_segments': {'1': (234, 234), '2': (206, 206), '3': (180, 180), '4': (154, 154), '5': (128, 128), '6': (102, 102), '7': (76, 76), '8': (50, 50), '9': (24, 24), '10': (0, 0), '11': (0, 0), '12': (0, 0), '13': (0, 0), '14': (0, 0), '15': (0, 0), '16': (0, 0), '17': (0, 0), '18': (0, 0), '19': (0, 0), '20': (0, 0), '21': (0, 0), '22': (0, 0), '23': (0, 0), '24': (0, 0), '25': (0, 0), '26': (0, 0), '27': (0, 0), '28': (0, 0), '29': (0, 0), '30': (0, 0), '31': (0, 0), '32': (0, 0), '33': (0, 0), '34': (0, 0), '35': (0, 0), '36': (0, 0), '37': (0, 0), '38': (0, 0), '39': (0, 0), '40': (0, 0), '41': (0, 0), '42': (0, 0), '43': (0, 0), '44': (0, 0), '45': (0, 0), '46': (0, 0), '47': (0, 0), '48': (0, 0), '49': (0, 0), '50': (0, 0), '51': (0, 0), '52': (0, 0), '53': (0, 0), '54': (0, 0), '55': (0, 0), '56': (0, 0), '57': (0, 0), '58': (0, 0), '59': (0, 0), '60': (0, 0), '61': (0, 0), '62': (0, 0), '63': (0, 0), '64': (0, 0), '65': (0, 0), '66': (0, 0), '67': (0, 0), '68': (0, 0), '69': (0, 0), '70': (0, 0), '71': (0, 0), '72': (0, 0), '73': (0, 0), '74': (0, 0), '75': (0, 0), '76': (0, 0), '77': (0, 0), '78': (0, 0), '79': (0, 0), '80': (0, 0), '81': (0, 0), '82': (0, 0), '83': (0, 0), '84': (0, 0), '85': (0, 0), '86': (0, 0), '87': (0, 0), '88': (0, 0), '89': (0, 0), '90': (0, 0), '91': (0, 0), '92': (0, 0), '93': (0, 0), '94': (0, 0), '95': (0, 0), '96': (0, 0), '97': (0, 0), '98': (0, 0), '99': (0, 0), '100': (0, 0), '101': (0, 0), '102': (0, 0), '103': (0, 0), '104': (0, 0), '105': (0, 0), '106': (0, 0), '107': (0, 0), '108': (0, 0), '109': (0, 0), '110': (0, 0), '111': (0, 0), '112': (0, 0), '113': (0, 0), '114': (0, 0), '115': (0, 0), '116': (0, 0), '117': (0, 0), '118': (0, 0), '119': (0, 0), '120': (0, 0), '121': (0, 0), '122': (0, 0), '123': (0, 0), '124': (0, 0), '125': (0, 0), '126': (0, 0), '127': (0, 0), '128': (0, 0), '129': (0, 0), '130': (0, 0), '131': (0, 0), '132': (0, 0), '133': (0, 0), '134': (0, 0), '135': (0, 0), '136': (0, 0), '137': (0, 0), '138': (0, 0), '139': (0, 0), '140': (0, 0), '141': (0, 0), '142': (0, 0), '143': (0, 0), '144': (0, 0), '145': (0, 0), '146': (0, 0), '147': (0, 0), '148': (0, 0), '149': (0, 0), '150': (0, 0), '151': (0, 0), '152': (0, 0), '153': (0, 0), '154': (0, 0), '155': (0, 0), '156': (0, 0), '157': (0, 0), '158': (0, 0), '159': (0, 0), '160': (0, 0), '161': (0, 0), '162': (0, 0), '163': (0, 0), '164': (0, 0), '165': (0, 0), '166': (0, 0), '167': (0, 0), '168': (0, 0), '169': (0, 0), '170': (0, 0), '171': (0, 0), '172': (0, 0), '173': (0, 0), '174': (0, 0), '175': (0, 0), '176': (0, 0), '177': (0, 0), '178': (0, 0), '179': (0, 0), '180': (0, 0), '181': (0, 0), '182': (0, 0), '183': (0, 0), '184': (0, 0), '185': (0, 0), '186': (0, 0), '187': (0, 0), '188': (0, 0), '189': (0, 0), '190': (0, 0), '191': (0, 0), '192': (0, 0), '193': (0, 0), '194': (0, 0), '195': (0, 0), '196': (0, 0), '197': (0, 0), '198': (0, 0), '199': (0, 0), '200': (0, 0), '201': (0, 0), '202': (0, 0), '203': (0, 0), '204': (0, 0), '205': (0, 0), '206': (0, 0), '207': (0, 0), '208': (0, 0), '209': (0, 0), '210': (0, 0), '211': (0, 0), '212': (0, 0), '213': (0, 0), '214': (0, 0), '215': (0, 0), '216': (0, 0), '217': (0, 0), '218': (0, 0), '219': (0, 0), '220': (0, 0), '221': (0, 0), '222': (0, 0), '223': (0, 0), '224': (0, 0), '225': (0, 0), '226': (0, 0), '227': (0, 0), '228': (0, 0), '229': (0, 0), '230': (0, 0), '231': (0, 0), '232': (0, 0), '233': (0, 0), '234': (0, 0), '235': (0, 0), '236': (0, 0), '237': (0, 0), '238': (0, 0), '239': (0, 0), '240': (0, 0), '241': (0, 0), '242': (0, 0), '243': (0, 0), '244': (0, 0), '245': (0, 0), '246': (0, 0), '247': (0, 0), '248': (0, 0), '249': (0, 0), '250': (0, 0), '251': (0, 0), '252': (0, 0), '253': (0, 0), '254': (0, 0), '255': (0, 0), '256': (0, 0), '257': (0, 0), '258': (0, 0), '259': (0, 0), '260': (0, 0), '261': (0, 0), '262': (0, 0), '263': (0, 0), '264': (0, 0), '265': (0, 0), '266': (0, 0), '267': (0, 0), '268': (0, 0), '269': (0, 0), '270': (0, 0)}, 'validation_score': None, 'fn_div_count_unconnected_parent': False, 'num_error_free_tracks': 28, 'num_rec_cells_last_frame': 30, 'num_gt_cells_last_frame': 28, 'node_recall': 1.0, 'edge_recall': 0.9829059829059829}\n", + "2022-06-24 13:44:41,017 linajea.utils.candidate_database INFO writing scores for {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 9, 'weight_division': -11, 'division_constant': 6.0, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'feature_func': 'noop', 'val': True} to {'param_id': 43, 'matching_threshold': 15, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'validation_score': False, 'window_size': 50, 'filter_short_tracklets_len': -1, 'ignore_one_off_div_errors': False, 'fn_div_count_unconnected_parent': True, 'sparse': False}\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "import importlib\n", + "importlib.reload(linajea.evaluation)\n", + "args.param_ids = [9, 6, 52, 17, 43]\n", + "\n", + "print(args.param_ids)\n", + "#args.param_ids = parameters_ids\n", + "for inf_config in getNextInferenceData(args, is_evaluate=True):\n", + " t = linajea.evaluation.evaluate_setup(inf_config)\n", + " print(t)" + ] + }, + { + "cell_type": "markdown", + "id": "d174b602", + "metadata": {}, + "source": [ + "### Predict Test Data\n", + "\n", + "Now that we know which ILP hyperparameters to use we can predict the `cell_indicator` and `movement_vectors` on the test data and compute the tracks. Make sure that `args.validation` is set to `False` and `solve.grid_search` and `solve.random_search` are set to `False`." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "e396ec9a", + "metadata": {}, + "outputs": [], + "source": [ + "config.solve.grid_search = False\n", + "config.solve.random_search = False\n", + "args.config = dump_config(config)\n", + "args.validation = False" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "73142808", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-24 10:50:02,323 linajea.utils.check_or_create_db INFO linajea_celegans_20220623_180937: {'setup_dir': 'mskc_test_1', 'iteration': 10, 'cell_score_threshold': 0.2, 'sample': 'mskcc_emb3'} (accessed)\n", + "2022-06-24 10:50:02,331 linajea.process_blockwise.predict_blockwise INFO Following ROIs in world units:\n", + "2022-06-24 10:50:02,331 linajea.process_blockwise.predict_blockwise INFO Input ROI = [45:65, -85:315, -50:690, -50:690] (20, 400, 740, 740)\n", + "2022-06-24 10:50:02,332 linajea.process_blockwise.predict_blockwise INFO Block read ROI = [-3:4, -85:315, -50:210, -50:210] (7, 400, 260, 260)\n", + "2022-06-24 10:50:02,332 linajea.process_blockwise.predict_blockwise INFO Block write ROI = [0:1, 0:230, 0:160, 0:160] (1, 230, 160, 160)\n", + "2022-06-24 10:50:02,332 linajea.process_blockwise.predict_blockwise INFO Output ROI = [48:62, 0:230, 0:640, 0:640] (14, 230, 640, 640)\n", + "2022-06-24 10:50:02,332 linajea.process_blockwise.predict_blockwise INFO Starting block-wise processing...\n", + "2022-06-24 10:50:02,333 linajea.process_blockwise.predict_blockwise INFO Sample: /nrs/funke/hirschp/mskcc_emb3\n", + "2022-06-24 10:50:02,333 linajea.process_blockwise.predict_blockwise INFO DB: linajea_celegans_20220623_180937\n", + "2022-06-24 10:50:02,388 daisy.scheduler INFO Scheduling 224 tasks to completion.\n", + "2022-06-24 10:50:02,388 daisy.scheduler INFO Max parallelism seems to be 224.\n", + "2022-06-24 10:50:02,389 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", + "\t\t0 finished (0 skipped, 0 succeeded, 0 failed), 0 processing, 224 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:50:04,072 daisy.scheduler INFO Launching workers for task BlockwiseTask\n", + "2022-06-24 10:50:04,099 linajea.process_blockwise.predict_blockwise INFO Starting predict worker...\n", + "2022-06-24 10:50:04,103 linajea.process_blockwise.predict_blockwise INFO Command: ['python -u /groups/funke/home/hirschp/tracking/linajea/linajea/prediction/predict.py --config tmp_configs/config_1656082202.324147.toml']\n", + "2022-06-24 10:50:12,400 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:50:12,402 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", + "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:50:22,412 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:50:22,413 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", + "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:50:32,423 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:50:32,424 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", + "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:50:42,435 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:50:42,436 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", + "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:50:52,446 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:50:52,447 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", + "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:51:02,457 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:51:02,458 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", + "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:51:12,469 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:51:12,470 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", + "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:51:22,480 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:51:22,481 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", + "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:51:32,492 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:51:32,492 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:51:42,503 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:51:42,504 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:51:52,514 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:51:52,515 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:52:02,525 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:52:02,526 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:52:12,537 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:52:12,538 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:52:22,548 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:52:22,549 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:52:32,559 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:52:32,560 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:52:42,570 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:52:42,571 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:52:52,582 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:52:52,583 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:53:02,593 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:53:02,594 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:53:12,604 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:53:12,605 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t126 finished (124 skipped, 2 succeeded, 0 failed), 2 processing, 96 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:53:22,615 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:53:22,616 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t141 finished (134 skipped, 7 succeeded, 0 failed), 1 processing, 82 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:53:32,627 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:53:32,627 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t150 finished (139 skipped, 11 succeeded, 0 failed), 2 processing, 72 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:53:42,638 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:53:42,639 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t157 finished (140 skipped, 17 succeeded, 0 failed), 1 processing, 66 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:53:52,649 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:53:52,650 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t164 finished (143 skipped, 21 succeeded, 0 failed), 2 processing, 58 pending\n", + "\t\tETA: unknown\n", + "2022-06-24 10:54:02,660 daisy.scheduler INFO \n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-24 10:54:02,661 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t169 finished (143 skipped, 26 succeeded, 0 failed), 2 processing, 53 pending\n", + "\t\tETA: 0:04:04.615385\n", + "2022-06-24 10:54:12,671 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:54:12,672 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t174 finished (143 skipped, 31 succeeded, 0 failed), 2 processing, 48 pending\n", + "\t\tETA: 0:03:41.538462\n", + "2022-06-24 10:54:22,683 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:54:22,683 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t180 finished (144 skipped, 36 succeeded, 0 failed), 2 processing, 42 pending\n", + "\t\tETA: 0:03:13.846154\n", + "2022-06-24 10:54:32,694 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:54:32,695 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t185 finished (144 skipped, 41 succeeded, 0 failed), 2 processing, 37 pending\n", + "\t\tETA: 0:02:50.769231\n", + "2022-06-24 10:54:42,705 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:54:42,706 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t190 finished (144 skipped, 46 succeeded, 0 failed), 1 processing, 33 pending\n", + "\t\tETA: 0:02:32.307692\n", + "2022-06-24 10:54:52,716 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:54:52,717 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t195 finished (144 skipped, 51 succeeded, 0 failed), 1 processing, 28 pending\n", + "\t\tETA: 0:02:09.230769\n", + "2022-06-24 10:55:02,727 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:55:02,728 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t200 finished (144 skipped, 56 succeeded, 0 failed), 1 processing, 23 pending\n", + "\t\tETA: 0:01:46.153846\n", + "2022-06-24 10:55:12,739 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:55:12,739 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t205 finished (144 skipped, 61 succeeded, 0 failed), 1 processing, 18 pending\n", + "\t\tETA: 0:01:23.076923\n", + "2022-06-24 10:55:22,750 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:55:22,751 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t209 finished (144 skipped, 65 succeeded, 0 failed), 2 processing, 13 pending\n", + "\t\tETA: 0:01:00\n", + "2022-06-24 10:55:32,761 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:55:32,762 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t214 finished (144 skipped, 70 succeeded, 0 failed), 2 processing, 8 pending\n", + "\t\tETA: 0:00:36.923077\n", + "2022-06-24 10:55:42,772 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:55:42,773 daisy.scheduler INFO \n", + "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", + "\t\t219 finished (144 skipped, 75 succeeded, 0 failed), 2 processing, 3 pending\n", + "\t\tETA: 0:00:13.846154\n", + "2022-06-24 10:55:52,784 daisy.scheduler INFO \n", + "\n", + "2022-06-24 10:55:57,224 linajea.process_blockwise.predict_blockwise INFO Predict worker finished\n", + "2022-06-24 10:55:57,242 daisy.scheduler INFO Ran 224 tasks of which 80 succeeded, 144 were skipped, 0 were orphaned (failed dependencies), 0 tasks failed (0 failed check, 0 application errors, 0 network failures or app crashes)\n" + ] + } + ], + "source": [ + "for inf_config in getNextInferenceData(args):\n", + " predict_blockwise(inf_config)" + ] + }, + { + "cell_type": "markdown", + "id": "25c0660d", + "metadata": {}, + "source": [ + "### Solve on Test Data\n", + "\n", + "Then we can solve the ILP on the test data. We select the hyperparameters that resulted in the lowest overall number of errors on the validation data." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "e1c7fe27", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-27 11:04:01,266 linajea.utils.check_or_create_db INFO linajea_celegans_20220624_134111: {'setup_dir': 'mskc_test_1', 'iteration': 10, 'cell_score_threshold': 0.2, 'sample': 'mskcc_emb'} (accessed)\n", + "2022-06-27 11:04:01,267 linajea.evaluation.analyze_results INFO checking db: linajea_celegans_20220624_134111\n", + "2022-06-27 11:04:01,295 linajea.utils.candidate_database INFO Query: {'val': True, 'matching_threshold': 15, 'validation_score': False, 'window_size': 50, 'filter_short_tracklets_len': -1, 'ignore_one_off_div_errors': False, 'fn_div_count_unconnected_parent': True, 'sparse': False}\n", + "2022-06-27 11:04:01,297 linajea.utils.candidate_database INFO Found 7 scores\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[SolveParametersMinimalConfig(track_cost=20.675083335777686, weight_node_score=-16.786125363574673, selection_constant=8.893522542354866, weight_division=-8.260920358667896, division_constant=5.9535746121046165, weight_child=1.003401421433546, weight_continuation=-1.0264581546174496, weight_edge_score=0.4254227576366175, cell_cycle_key=None, block_size=[15, 512, 512, 712], context=[2, 100, 100, 100], max_cell_move=45, roi=None, feature_func='noop', val=False, tag=None)] 1\n", + "getting results for: /nrs/funke/hirschp/mskcc_emb\n", + "SolveParametersMinimalConfig(track_cost=7.0, weight_node_score=-21.0, selection_constant=6.0, weight_division=-11.0, division_constant=5.9535746121046165, weight_child=1.0, weight_continuation=-1.0, weight_edge_score=0.35, cell_cycle_key=None, block_size=[15, 512, 512, 712], context=[2, 100, 100, 100], max_cell_move=None, roi=None, feature_func='noop', val=False, tag=None) \n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_1479236/396560302.py:40: FutureWarning: Dropping invalid columns in DataFrameGroupBy.agg is deprecated. In a future version, a TypeError will be raised. Before calling .agg, select only columns which should be valid for the aggregating function.\n", + " results = results.groupby(by, dropna=False, as_index=False).agg(\n" + ] + } + ], + "source": [ + "score_columns = ['fn_edges', 'identity_switches',\n", + " 'fp_divisions', 'fn_divisions']\n", + "if not config.general.sparse:\n", + " score_columns = ['fp_edges'] + score_columns\n", + "\n", + "sort_by = \"sum_errors\"\n", + "results = {}\n", + "args.validation = True\n", + "for sample_idx, inf_config in enumerate(getNextInferenceData(args,\n", + " is_evaluate=True)):\n", + " sample = inf_config.inference.data_source.datafile.filename\n", + " print(\"getting results for:\", sample)\n", + "\n", + " res = linajea.evaluation.get_results_sorted(\n", + " inf_config,\n", + " filter_params={\"val\": True},\n", + " score_columns=score_columns,\n", + " sort_by=sort_by)\n", + "\n", + " results[os.path.basename(sample)] = res.reset_index()\n", + "args.validation = False\n", + "\n", + "results = pd.concat(list(results.values())).reset_index()\n", + "del results['param_id']\n", + "del results['_id']\n", + "\n", + "by = [\n", + " #\"cell_cycle_key\",\n", + " #\"filter_polar_bodies_key\",\n", + " \"matching_threshold\",\n", + " \"weight_node_score\",\n", + " \"selection_constant\",\n", + " \"track_cost\",\n", + " \"weight_division\",\n", + " \"division_constant\",\n", + " \"weight_child\",\n", + " \"weight_continuation\",\n", + " \"weight_edge_score\",\n", + "]\n", + "results = results.groupby(by, dropna=False, as_index=False).agg(\n", + " lambda x: -1 if len(x) != sample_idx+1 else sum(x))\n", + "\n", + "results = results[results.sum_errors != -1]\n", + "results.sort_values(sort_by, ascending=False, inplace=True)\n", + "\n", + "#print(results)\n", + "\n", + "config.solve.parameters = [config.solve.parameters[0]]\n", + "config.solve.parameters[0].weight_node_score = float(results.at[0, 'weight_node_score'])\n", + "config.solve.parameters[0].selection_constant = float(results.at[0, 'selection_constant'])\n", + "config.solve.parameters[0].track_cost = float(results.at[0, 'track_cost'])\n", + "config.solve.parameters[0].weight_edge_score = float(results.at[0, 'weight_edge_score'])\n", + "config.solve.parameters[0].weight_division = float(results.at[0, 'weight_division'])\n", + "config.solve.parameters[0].weight_child = float(results.at[0, 'weight_child'])\n", + "config.solve.parameters[0].weight_continuation = float(results.at[0, 'weight_continuation'])\n", + "print(config.solve.parameters[0], type(config.solve.parameters[0].weight_continuation))\n", + "args.config = dump_config(config)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "7a141c33", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-06-24 13:51:58,318 linajea.utils.check_or_create_db INFO linajea_celegans_20220623_180937: {'setup_dir': 'mskc_test_1', 'iteration': 10, 'cell_score_threshold': 0.2, 'sample': 'mskcc_emb3'} (accessed)\n", + "2022-06-24 13:51:58,344 linajea.utils.candidate_database INFO Querying ID for parameters {'track_cost': 7.0, 'weight_node_score': -21.0, 'selection_constant': 6.0, 'weight_division': -11.0, 'division_constant': 5.9535746121046165, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': False, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}}\n", + "2022-06-24 13:51:58,348 linajea.utils.candidate_database INFO Parameters {'track_cost': 7.0, 'weight_node_score': -21.0, 'selection_constant': 6.0, 'weight_division': -11.0, 'division_constant': 5.9535746121046165, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': False, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}} not yet in collection, adding with id 3\n", + "2022-06-24 13:51:58,351 linajea.utils.candidate_database INFO Resetting solution for parameter ids [3]\n", + "2022-06-24 13:51:58,368 linajea.utils.candidate_database INFO Resetting soln for parameter_ids [3] in roi None took 0 seconds\n", + "2022-06-24 13:51:58,370 linajea.process_blockwise.solve_blockwise INFO Solving in [48:62, -100:305, -100:612, -100:612] (14, 405, 712, 712)\n", + "2022-06-24 13:51:58,370 linajea.process_blockwise.solve_blockwise INFO Sample: /nrs/funke/hirschp/mskcc_emb3\n", + "2022-06-24 13:51:58,371 linajea.process_blockwise.solve_blockwise INFO DB: linajea_celegans_20220623_180937\n", + "2022-06-24 13:51:58,558 linajea.process_blockwise.solve_blockwise INFO Solving in block linajea_solving/1 with read ROI [48:67, -100:612, -100:612, -100:812] (19, 712, 712, 912) and write ROI [50:65, 0:512, 0:512, 0:712] (15, 512, 512, 712)\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "e2e95e47b6c54fe68515425c7453f222", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "linajea_solving ▶: 0%| | 0/1 [00:00 0: + cmd = ['sbatch', '../run_slurm_gpu.sh'] + cmd[1:] + else: + cmd = ['sbatch', '../run_slurm_cpu.sh'] + cmd[1:] + elif args.gridengine: + if num_gpus > 0: + cmd = ['qsub', '../run_gridengine_gpu.sh'] + cmd[1:] + else: + cmd = ['qsub', '../run_gridengine_cpu.sh'] + cmd[1:] + print(cmd) + output = subprocess.run(cmd, check=True) + else: + if args.local: + output = subprocess.run( + cmd, + check=True, + encoding='UTF-8') + else: + output = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding='UTF-8') + + jobid = None + if not args.local: + bsub_stdout_regex = re.compile("Job <(\d+)> is submitted*") + logger.debug("Command output: %s" % output) + print(output.stdout) + print(output.stderr) + match = bsub_stdout_regex.match(output.stdout) + jobid = match.group(1) + print(jobid) + if wait and \ + not subprocess.run(["bwait", "-w", 'ended({})'.format(jobid)]): + print("{} failed".format(cmd)) + exit() + + return jobid + + +if __name__ == "__main__": + print(sys.argv) + parser = argparse.ArgumentParser() + parser.add_argument('--config', type=str, + help='path to config file') + parser.add_argument('--checkpoint', type=int, default=-1, + help='checkpoint to (post)process') + parser.add_argument('--train', action="store_true", + dest='run_train', help='run train?') + parser.add_argument('--predict', action="store_true", + dest='run_predict', help='run predict?') + parser.add_argument('--extract_edges', action="store_true", + dest='run_extract_edges', + help='run extract edges?') + parser.add_argument('--solve', action="store_true", + dest='run_solve', help='run solve?') + parser.add_argument('--evaluate', action="store_true", + dest='run_evaluate', help='run evaluate?') + parser.add_argument('--validation', action="store_true", + help='use validation data?') + parser.add_argument('--validate_on_train', action="store_true", + help='validate on train data?') + parser.add_argument('--param_id', type=int, default=None, + help='eval parameters_id') + parser.add_argument('--val_param_id', type=int, default=None, + help='use validation parameters_id') + parser.add_argument("--run_from_exp", action="store_true", + help='run from setup or from experiment folder') + parser.add_argument("--local", action="store_true", + help='run locally or on cluster?') + parser.add_argument("--slurm", action="store_true", + help='run on slurm cluster?') + parser.add_argument("--gridengine", action="store_true", + help='run on gridengine cluster?') + parser.add_argument("--interactive", action="store_true", + help='run on interactive node on cluster?') + parser.add_argument('--array_job', action="store_true", + help='submit each parameter set for solving/eval as one job?') + parser.add_argument('--eval_array_job', action="store_true", + help='submit each parameter set for eval as one job?') + parser.add_argument('--param_ids', default=None, nargs=2, + help='start/end range of eval parameters_ids') + parser.add_argument('--wait_job_id', type=str, default=None, + help='wait for this job before starting') + parser.add_argument("--no_block_after_eval", dest="block_after_eval", + action="store_false", + help='block after starting eval jobs?') + + + args = parser.parse_args() + config = maybe_fix_config_paths_to_machine_and_load(args.config) + config = TrackingConfig(**config) + setup_dir = config.general.setup_dir + script_dir = os.path.dirname(os.path.abspath(__file__)) + is_new_run = not os.path.exists(setup_dir) + + os.makedirs(setup_dir, exist_ok=True) + os.makedirs(os.path.join(setup_dir, "tmp_configs"), exist_ok=True) + + if not is_new_run and \ + os.path.dirname(os.path.abspath(args.config)) != \ + os.path.abspath(setup_dir) and \ + "tmp_configs" not in args.config: + raise RuntimeError( + "overwriting config with external config file (%s - %s)", + args.config, setup_dir) + config_file = os.path.basename(args.config) + if "tmp_configs" not in args.config: + backup_and_copy_file(os.path.dirname(args.config), + setup_dir, + os.path.basename(args.config)) + if is_new_run: + config.path = os.path.join(setup_dir, os.path.basename(args.config)) + + os.chdir(setup_dir) + os.makedirs("logs", exist_ok=True) + + logging.basicConfig( + level=config.general.logging, + handlers=[ + logging.FileHandler("run.log", mode='a'), + logging.StreamHandler(sys.stdout) + ], + format='%(asctime)s %(name)s %(levelname)-8s %(message)s') + + os.environ["GRB_LICENSE_FILE"] = "/misc/local/gurobi-9.1.2/gurobi.lic" + run_steps = [] + if args.run_train: + run_steps.append("01_train.py") + if args.run_predict: + run_steps.append("02_predict_blockwise.py") + if args.run_extract_edges: + run_steps.append("03_extract_edges.py") + if args.run_solve: + run_steps.append("04_solve.py") + if args.run_evaluate: + run_steps.append("05_evaluate.py") + + + config.path = os.path.join("tmp_configs", "config_{}.toml".format( + time.time())) + config_dict = attr.asdict(config) + config_dict['solve']['grid_search'] = False + config_dict['solve']['random_search'] = False + with open(config.path, 'w') as f: + toml.dump(config_dict, f) + + jobid = args.wait_job_id + for step in run_steps: + cmd = ["python", + os.path.join(script_dir, step)] + if args.checkpoint > 0: + cmd.append("--checkpoint") + cmd.append(str(args.checkpoint)) + if args.validation: + cmd.append("--validation") + if args.validate_on_train: + cmd.append("--validate_on_train") + + cmd += ["--config", config.path] + + if step == "01_train.py": + do_train(args, config, cmd) + elif step == "02_predict_blockwise.py": + do_predict(args, config, cmd) + elif step == "03_extract_edges.py": + do_extract_edges(args, config, cmd) + elif step == "04_solve.py": + jobid = do_solve(args, config, cmd) + elif step == "05_evaluate.py": + do_evaluate(args, config, cmd, jobid=jobid, wait=args.block_after_eval) + + else: + raise RuntimeError("invalid processing step! %s", step) diff --git a/linajea/run_scripts/run_single_sample.ipynb b/linajea/run_scripts/run_single_sample.ipynb new file mode 100644 index 0000000..cade317 --- /dev/null +++ b/linajea/run_scripts/run_single_sample.ipynb @@ -0,0 +1,359 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0754861b", + "metadata": {}, + "source": [ + "Linajea Tracking Example\n", + "=====================\n", + "\n", + "\n", + "This example show all steps necessary to generate the final tracks, from training the network to finding the optimal ILP weights on the validation data to computing the tracks on the test data.\n", + "\n", + "- train network\n", + "- predict on validation data\n", + "- grid search weights for ILP\n", + " - solve once per set of weights\n", + " - evaluate once per set of weights\n", + " - select set with fewest errors\n", + "- predict on test data\n", + "- solve on test data with optimal weights\n", + "- evaluate on test data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5c7284e", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "import logging\n", + "import os\n", + "import sys\n", + "import time\n", + "import types\n", + "\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "from linajea.config import TrackingConfig\n", + "import linajea.evaluation\n", + "from linajea.process_blockwise import (extract_edges_blockwise,\n", + " predict_blockwise,\n", + " solve_blockwise)\n", + "from linajea.training import train\n", + "import linajea.config\n", + "import linajea.process_blockwise\n", + "import linajea" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dffaf22c", + "metadata": {}, + "outputs": [], + "source": [ + "logging.basicConfig(\n", + " level=logging.INFO,\n", + " format='%(asctime)s %(name)s %(levelname)-8s %(message)s')" + ] + }, + { + "cell_type": "markdown", + "id": "428b2706", + "metadata": {}, + "source": [ + "Configuration\n", + "--------------------\n", + "\n", + "All parameters to control the pipeline (e.g. model architecture, data augmentation, training parameters, ILP weights) are contained in a configuration file (in the TOML format https://toml.io)\n", + "\n", + "You can use a single monolithic configuration file or separate configuration files for a subset of the steps of the pipeline, as long as the parameters required for the respective steps are there.\n", + "\n", + "Familiarize yourself with the example configuration files and have a look at the documentation for the configuration to see what is needed. Most parameters have sensible defaults; usually setting the correct paths and the data configuration is all that is needed to start. See `run_multiple_samples.ipynb` for an example setup that can (optionally) handle multiple samples and automates the process of selecting the correct data for each step as much as possible. For training `train_data` has to be set, and for validation and testing `inference_data`.\n", + "\n", + "You can modify the `config_file` variable to point to the config file you would like to use. Make sure that the file paths contained in it point to the correct destination, for instance that they are adapted to your directory structure." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a28ca24d", + "metadata": {}, + "outputs": [], + "source": [ + "train_config_file = \"config_example_single_sample_train.toml\"\n", + "train_config = TrackingConfig.from_file(train_config_file)\n", + "os.makedirs(train_config.general.setup_dir, exist_ok=True)" + ] + }, + { + "cell_type": "markdown", + "id": "79e4a402", + "metadata": {}, + "source": [ + "Training\n", + "------------\n", + "\n", + "To start training simply pass the configuration object to the train function. Make sure that the training data and parameters such as the number of iterations/setps are set correctly.\n", + "\n", + "To train until convergence will take from several hours to multiple days." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f2ae694b", + "metadata": {}, + "outputs": [], + "source": [ + "train(train_config)" + ] + }, + { + "cell_type": "markdown", + "id": "213c022b", + "metadata": {}, + "source": [ + "Validation\n", + "--------------\n", + "\n", + "After the training is completed we first have to determine the optimal ILP weights.\n", + "This is achieved by first creating the prediction on the validation data and then performing a grid search by solving the ILP and evaluating the results repeatedly.\n", + "\n", + "MongoDB is used to store the computed results. A `mongod` server has to be running before executing the remaining cells.\n", + "See https://www.mongodb.com/docs/manual/administration/install-community/ for a guide on how to install it (Linux/Windows/MacOS)\n", + "Alternatively you might want to create a singularity image (https://github.com/singularityhub/mongo). This can be used locally but will be necessary if you want to run the code on an HPC cluster and there is no server installed already." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6949e361", + "metadata": {}, + "outputs": [], + "source": [ + "validation_config_file = \"config_example_single_sample_val.toml\"\n", + "val_config = TrackingConfig.from_file(validation_config_file)\n", + "os.makedirs(val_config.general.setup_dir, exist_ok=True)" + ] + }, + { + "cell_type": "markdown", + "id": "8a240c93", + "metadata": {}, + "source": [ + "### Predict Validation Data\n", + "\n", + "To predict the `cell_indicator` and `movement_vectors` on the validation data make sure that `args.validation` is set to `True`, then execute the next cell.\n", + "\n", + "Depending on the number of workers used (see config file) and the size of the data this can take a while." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4e9124e3", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "predict_blockwise(val_config)" + ] + }, + { + "cell_type": "markdown", + "id": "4ba90ec2", + "metadata": {}, + "source": [ + "### Extract Edges Validation Data\n", + "\n", + "For each detected cell, look for neighboring cells in the next time frame and insert an edge candidate for each into the database." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e8e3ca0", + "metadata": {}, + "outputs": [], + "source": [ + "extract_edges_blockwise(val_config)" + ] + }, + { + "cell_type": "markdown", + "id": "b032f671", + "metadata": {}, + "source": [ + "### ILP Weights Grid Search\n", + "\n", + "#### Solve on Validation Data\n", + "\n", + "Make sure that `solve.grid_search` is set to `True`. The parameter sets to try are generated automatically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a4fc5ae", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "linajea.process_blockwise.solve_blockwise(val_config)" + ] + }, + { + "cell_type": "markdown", + "id": "b1a28d14", + "metadata": {}, + "source": [ + "#### Evaluate on Validation Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0bcc596e", + "metadata": {}, + "outputs": [], + "source": [ + "validation_config_file = \"config_example_single_sample_val.toml\"\n", + "val_config = TrackingConfig.from_file(validation_config_file)\n", + "parameters = val_config.solve.parameters\n", + "for params in parameters:\n", + " val_config.solve.parameters = [params]\n", + " linajea.evaluation.evaluate_setup(val_config)" + ] + }, + { + "cell_type": "markdown", + "id": "d174b602", + "metadata": {}, + "source": [ + "### Predict Test Data\n", + "\n", + "Now that we know which ILP weights to use we can predict the `cell_indicator` and `movement_vectors` on the test data and compute the tracks. Make sure that `args.validation` is set to `False` and `solve.grid_search` and `solve.random_search` are set to `False`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f1dade09", + "metadata": {}, + "outputs": [], + "source": [ + "test_config_file = \"config_example_single_sample_test.toml\"\n", + "test_config = TrackingConfig.from_file(test_config_file)\n", + "print(test_config.inference_data.data_source)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73142808", + "metadata": {}, + "outputs": [], + "source": [ + "predict_blockwise(test_config)" + ] + }, + { + "cell_type": "markdown", + "id": "25c0660d", + "metadata": {}, + "source": [ + "### Solve on Test Data\n", + "\n", + "Then we can solve the ILP on the test data. We select the ILP weights that resulted in the lowest overall number of errors on the validation data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e1c7fe27", + "metadata": {}, + "outputs": [], + "source": [ + "score_columns = ['fn_edges', 'identity_switches',\n", + " 'fp_divisions', 'fn_divisions']\n", + "if not val_config.general.sparse:\n", + " score_columns = ['fp_edges'] + score_columns\n", + "\n", + "results = linajea.evaluation.get_results_sorted(\n", + " val_config,\n", + " filter_params={\"val\": True},\n", + " score_columns=score_columns,\n", + " sort_by=\"sum_errors\")\n", + "\n", + "test_config.solve.parameters = [val_config.solve.parameters[0]]\n", + "test_config.solve.parameters[0].weight_node_score = float(results.iloc[0].weight_node_score)\n", + "test_config.solve.parameters[0].selection_constant = float(results.iloc[0].selection_constant)\n", + "test_config.solve.parameters[0].track_cost = float(results.iloc[0].track_cost)\n", + "test_config.solve.parameters[0].weight_edge_score = float(results.iloc[0].weight_edge_score)\n", + "test_config.solve.parameters[0].weight_division = float(results.iloc[0].weight_division)\n", + "test_config.solve.parameters[0].weight_child = float(results.iloc[0].weight_child)\n", + "test_config.solve.parameters[0].weight_continuation = float(results.iloc[0].weight_continuation)\n", + "print(test_config.solve.parameters[0], type(test_config.solve.parameters[0].weight_continuation))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7a141c33", + "metadata": {}, + "outputs": [], + "source": [ + "solve_blockwise(test_config)" + ] + }, + { + "cell_type": "markdown", + "id": "4448cb8d", + "metadata": {}, + "source": [ + "### Evaluate on Test Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "03698bce", + "metadata": {}, + "outputs": [], + "source": [ + "report = linajea.evaluation.evaluate_setup(test_config)\n", + "print(report.get_short_report())" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python [conda env:linajea_torch] *", + "language": "python", + "name": "conda-env-linajea_torch-py" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 2ef45fb9baaa76beeb603b110de29c3ebb61b14c Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 14:32:43 -0400 Subject: [PATCH 192/263] add train script from experiments repo --- linajea/training/__init__.py | 2 + linajea/training/torch_loss.py | 321 +++++++++++++++ linajea/training/torch_model.py | 198 +++++++++ linajea/training/train.py | 701 ++++++++++++++++++++++++++++++++ linajea/training/utils.py | 136 +++++++ 5 files changed, 1358 insertions(+) create mode 100644 linajea/training/__init__.py create mode 100644 linajea/training/torch_loss.py create mode 100644 linajea/training/torch_model.py create mode 100644 linajea/training/train.py create mode 100644 linajea/training/utils.py diff --git a/linajea/training/__init__.py b/linajea/training/__init__.py new file mode 100644 index 0000000..1a45a15 --- /dev/null +++ b/linajea/training/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa +from .train import train diff --git a/linajea/training/torch_loss.py b/linajea/training/torch_loss.py new file mode 100644 index 0000000..5709214 --- /dev/null +++ b/linajea/training/torch_loss.py @@ -0,0 +1,321 @@ +import torch +# from torchvision.utils import save_image +# import numpy as np + +from . import torch_model + + +class LossWrapper(torch.nn.Module): + def __init__(self, config, current_step=0): + super().__init__() + self.config = config + self.voxel_size = torch.nn.Parameter( + torch.FloatTensor(self.config.train_data.voxel_size[1:]), + requires_grad=False) + self.current_step = torch.nn.Parameter( + torch.tensor(float(current_step)), requires_grad=False) + + def weighted_mse_loss(inputs, target, weight): + # print(((inputs - target) ** 2).size()) + # print((weight * ((inputs - target) ** 2)).size()) + # return (weight * ((inputs - target) ** 2)).mean() + # print((weight * ((inputs - target) ** 2)).sum(), weight.sum()) + ws = weight.sum() * inputs.size()[0] / weight.size()[0] + if abs(ws) <= 0.001: + ws = 1 + return (weight * ((inputs - target) ** 2)).sum()/ ws + + def weighted_mse_loss2(inputs, target, weight): + # print(((inputs - target) ** 2).size()) + # print((weight * ((inputs - target) ** 2)).size()) + # return (weight * ((inputs - target) ** 2)).mean() + # print((weight * ((inputs - target) ** 2)).sum(), weight.sum()) + # ws = weight.sum() + # if ws == 0: + # ws = 1 + return (weight * ((inputs - target) ** 2)).mean() + + # self.pv_loss = torch.nn.MSELoss(reduction='mean') + # self.ci_loss = torch.nn.MSELoss(reduction='mean') + self.pv_loss = weighted_mse_loss + self.ci_loss = weighted_mse_loss2 + + met_sum_intv = 10 + loss_sum_intv = 1 + self.summaries = { + "loss": [-1, loss_sum_intv], + "alpha": [-1, loss_sum_intv], + "cell_indicator_loss": [-1, loss_sum_intv], + "parent_vectors_loss_cell_mask": [-1, loss_sum_intv], + "parent_vectors_loss_maxima": [-1, loss_sum_intv], + "parent_vectors_loss": [-1, loss_sum_intv], + 'cell_ind_tpr_gt': [-1, met_sum_intv], + 'par_vec_cos_gt': [-1, met_sum_intv], + 'par_vec_diff_mn_gt': [-1, met_sum_intv], + 'par_vec_tpr_gt': [-1, met_sum_intv], + 'cell_ind_tpr_pred': [-1, met_sum_intv], + 'par_vec_cos_pred': [-1, met_sum_intv], + 'par_vec_diff_mn_pred': [-1, met_sum_intv], + 'par_vec_tpr_pred': [-1, met_sum_intv], + } + + def metric_summaries(self, + gt_cell_center, + cell_indicator, + cell_indicator_cropped, + gt_parent_vectors_cropped, + parent_vectors_cropped, + maxima, + maxima_in_cell_mask, + output_shape_2): + + + # ground truth cell locations + gt_max_loc = torch.nonzero(gt_cell_center > 0.5) + + # predicted value at those locations + tmp = cell_indicator[list(gt_max_loc.T)] + # tmp = tf.gather_nd(cell_indicator, gt_max_loc) + # true positive if > 0.5 + cell_ind_tpr_gt = torch.mean((tmp > 0.5).float()) + + if not self.config.model.train_only_cell_indicator: + # crop to nms area + gt_cell_center_cropped = torch_model.crop( + # l=1, d, h, w + gt_cell_center, + # l=1, d', h', w' + output_shape_2) + tp_dims = [1, 2, 3, 4, 0] + + # cropped ground truth cell locations + gt_max_loc = torch.nonzero(gt_cell_center_cropped > 0.5) + + # ground truth parent vectors at those locations + tmp_gt_par = gt_parent_vectors_cropped.permute(*tp_dims)[list(gt_max_loc.T)] + + # predicted parent vectors at those locations + tmp_par = parent_vectors_cropped.permute(*tp_dims)[list(gt_max_loc.T)] + + # normalize predicted parent vectors + normalize_pred = torch.nn.functional.normalize(tmp_par, dim=1) + # normalize ground truth parent vectors + normalize_gt = torch.nn.functional.normalize(tmp_gt_par, dim=1) + + # cosine similarity predicted vs ground truth parent vectors + cos_similarity = torch.sum(normalize_pred * normalize_gt, + dim=1) + + # rate with cosine similarity > 0.9 + par_vec_cos_gt = torch.mean((cos_similarity > 0.9).float()) + + # distance between endpoints of predicted vs gt parent vectors + par_vec_diff = torch.linalg.vector_norm( + (tmp_gt_par / self.voxel_size) - (tmp_par / self.voxel_size), dim=1) + # mean distance + par_vec_diff_mn_gt = torch.mean(par_vec_diff) + + # rate with distance < 1 + par_vec_tpr_gt = torch.mean((par_vec_diff < 1).float()) + + # predicted cell locations + # pred_max_loc = torch.nonzero(torch.reshape(maxima_in_cell_mask, output_shape_2)) + pred_max_loc = torch.nonzero(torch.reshape( + torch.gt(maxima * cell_indicator_cropped, 0.2), output_shape_2)) + + # predicted value at those locations + tmp = cell_indicator_cropped[list(pred_max_loc.T)] + # assumed good if > 0.5 + cell_ind_tpr_pred = torch.mean((tmp > 0.5).float()) + + if not self.config.model.train_only_cell_indicator: + tp_dims = [1, 2, 3, 4, 0] + # ground truth parent vectors at those locations + tmp_gt_par = gt_parent_vectors_cropped.permute(*tp_dims)[list(pred_max_loc.T)] + + # predicted parent vectors at those locations + tmp_par = parent_vectors_cropped.permute(*tp_dims)[list(pred_max_loc.T)] + + # normalize predicted parent vectors + normalize_pred = torch.nn.functional.normalize(tmp_par, dim=1) + + # normalize ground truth parent vectors + normalize_gt = torch.nn.functional.normalize(tmp_gt_par, dim=1) + + # cosine similarity predicted vs ground truth parent vectors + cos_similarity = torch.sum(normalize_pred * normalize_gt, + dim=1) + + # rate with cosine similarity > 0.9 + par_vec_cos_pred = torch.mean((cos_similarity > 0.9).float()) + + # distance between endpoints of predicted vs gt parent vectors + par_vec_diff = torch.linalg.vector_norm( + (tmp_gt_par / self.voxel_size) - (tmp_par / self.voxel_size), dim=1) + + # mean distance + par_vec_diff_mn_pred = torch.mean(par_vec_diff) + + # rate with distance < 1 + par_vec_tpr_pred = torch.mean((par_vec_diff < 1).float()) + + self.summaries['cell_ind_tpr_gt'][0] = cell_ind_tpr_gt + self.summaries['cell_ind_tpr_pred'][0] = cell_ind_tpr_pred + if not self.config.model.train_only_cell_indicator: + self.summaries['par_vec_cos_gt'][0] = par_vec_cos_gt + self.summaries['par_vec_diff_mn_gt'][0] = par_vec_diff_mn_gt + self.summaries['par_vec_tpr_gt'][0] = par_vec_tpr_gt + self.summaries['par_vec_cos_pred'][0] = par_vec_cos_pred + self.summaries['par_vec_diff_mn_pred'][0] = par_vec_diff_mn_pred + self.summaries['par_vec_tpr_pred'][0] = par_vec_tpr_pred + + + def forward(self, *, + gt_cell_indicator, + cell_indicator, + maxima, + gt_cell_center, + cell_mask=None, + gt_parent_vectors=None, + parent_vectors=None + ): + + output_shape_1 = cell_indicator.size() + output_shape_2 = maxima.size() + + # raw_cropped = torch_model.crop(raw, output_shape_1) + cell_indicator_cropped = torch_model.crop( + # l=1, d, h, w + cell_indicator, + # l=1, d', h', w' + output_shape_2) + + if not self.config.model.train_only_cell_indicator: + cell_mask = torch.reshape(cell_mask, (1,) + output_shape_1) + # l=1, d', h', w' + cell_mask_cropped = torch_model.crop( + # l=1, d, h, w + cell_mask, + # l=1, d', h', w' + output_shape_2) + + # print(maxima.size(), cell_indicator_cropped.size(), cell_indicator.size()) + # maxima = torch.eq(maxima, cell_indicator_cropped) + + # l=1, d', h', w' + # maxima_in_cell_mask = torch.logical_and(maxima, cell_mask_cropped) + maxima_in_cell_mask = maxima.float() * cell_mask_cropped.float() + maxima_in_cell_mask = torch.reshape(maxima_in_cell_mask, + (1,) + output_shape_2) + + # c=3, l=1, d', h', w' + parent_vectors_cropped = torch_model.crop( + # c=3, l=1, d, h, w + parent_vectors, + # c=3, l=1, d', h', w' + (3,) + output_shape_2) + + # c=3, l=1, d', h', w' + gt_parent_vectors_cropped = torch_model.crop( + # c=3, l=1, d, h, w + gt_parent_vectors, + # c=3, l=1, d', h', w' + (3,) + output_shape_2) + + parent_vectors_loss_cell_mask = self.pv_loss( + # c=3, l=1, d, h, w + gt_parent_vectors, + # c=3, l=1, d, h, w + parent_vectors, + # c=1, l=1, d, h, w (broadcastable) + cell_mask) + + # cropped + parent_vectors_loss_maxima = self.pv_loss( + # c=3, l=1, d', h', w' + gt_parent_vectors_cropped, + # c=3, l=1, d', h', w' + parent_vectors_cropped, + # c=1, l=1, d', h', w' (broadcastable) + torch.reshape(maxima_in_cell_mask, (1,) + output_shape_2)) + else: + parent_vectors_cropped = None + gt_parent_vectors_cropped = None + maxima_in_cell_mask = None + + # non-cropped + if self.config.model.cell_indicator_weighted: + if isinstance(self.config.model.cell_indicator_weighted, bool): + self.config.model.cell_indicator_weighted = 0.00001 + cond = gt_cell_indicator < self.config.model.cell_indicator_cutoff + weight = torch.where(cond, self.config.model.cell_indicator_weighted, 1.0) + # print(cond.size(), weight.size()) + else: + weight = torch.tensor(1.0) + # print(torch.min(cell_indicator), torch.max(cell_indicator)) + # print(torch.min(gt_cell_indicator), torch.max(gt_cell_indicator)) + # for i, w in enumerate(torch.squeeze(weight, dim=0)): + # save_image(w, 'w_{}_{}.tif'.format(self.current_step, i)) + # for i, ci in enumerate(torch.squeeze(cell_indicator, dim=0)): + # save_image(ci, 'ci_{}_{}.tif'.format(self.current_step, i)) + # print(np.min(cell_indicator.detach().cpu().numpy()), + # np.max(cell_indicator.detach().cpu().numpy())) + cell_indicator_loss = self.ci_loss( + # l=1, d, h, w + gt_cell_indicator, + # l=1, d, h, w + cell_indicator, + # l=1, d, h, w + weight) + # cell_mask) + + # print(torch.sum(gt_cell_indicator), torch.sum(cell_indicator), torch.sum(weight)) + if self.config.model.train_only_cell_indicator: + loss = cell_indicator_loss + parent_vectors_loss = 0 + else: + if self.config.train.parent_vectors_loss_transition_offset: + # smooth transition from training parent vectors on complete cell mask to + # only on maxima + # https://www.wolframalpha.com/input/?i=1.0%2F(1.0+%2B+exp(0.01*(-x%2B20000)))+x%3D0+to+40000 + alpha = (1.0 / + (1.0 + torch.exp( + self.config.train.parent_vectors_loss_transition_factor * + (-self.current_step + self.config.train.parent_vectors_loss_transition_offset)))) + self.summaries['alpha'][0] = alpha + + # multiply cell indicator loss with parent vector loss, since they have + # different magnitudes (this normalizes gradients by magnitude of other + # loss: (uv)' = u'v + uv') + # loss = 1000 * cell_indicator_loss + 0.1 * ( + # parent_vectors_loss_maxima*alpha + + # parent_vectors_loss_cell_mask*(1.0 - alpha) + # ) + parent_vectors_loss = (parent_vectors_loss_maxima * alpha + + parent_vectors_loss_cell_mask * (1.0 - alpha) + ) + else: + # loss = 1000 * cell_indicator_loss + 0.1 * parent_vectors_loss_cell_mask + parent_vectors_loss = parent_vectors_loss_cell_mask + + loss = cell_indicator_loss + parent_vectors_loss + self.summaries['parent_vectors_loss_cell_mask'][0] = parent_vectors_loss_cell_mask + self.summaries['parent_vectors_loss_maxima'][0] = parent_vectors_loss_maxima + self.summaries['parent_vectors_loss'][0] = parent_vectors_loss + # print(loss, cell_indicator_loss, parent_vectors_loss) + + self.summaries['loss'][0] = loss + self.summaries['cell_indicator_loss'][0] = cell_indicator_loss + + self.metric_summaries( + gt_cell_center, + cell_indicator, + cell_indicator_cropped, + gt_parent_vectors_cropped, + parent_vectors_cropped, + maxima, + maxima_in_cell_mask, + output_shape_2) + + self.current_step += 1 + return loss, cell_indicator_loss, parent_vectors_loss, self.summaries, torch.sum(cell_indicator_cropped) diff --git a/linajea/training/torch_model.py b/linajea/training/torch_model.py new file mode 100644 index 0000000..8826e51 --- /dev/null +++ b/linajea/training/torch_model.py @@ -0,0 +1,198 @@ +import json +import logging +import os + +import torch + +from funlib.learn.torch.models import UNet, ConvPass + +from .utils import (crop, + crop_to_factor) + +logger = logging.getLogger(__name__) + + +class UnetModelWrapper(torch.nn.Module): + def __init__(self, config, current_step=0): + super().__init__() + self.config = config + self.current_step = current_step + + num_fmaps = (config.model.num_fmaps + if isinstance(config.model.num_fmaps, list) + else [config.model.num_fmaps, config.model.num_fmaps]) + + if config.model.unet_style == "split" or \ + config.model.unet_style == "single" or \ + self.config.model.train_only_cell_indicator: + num_heads = 1 + elif config.model.unet_style == "multihead": + num_heads = 2 + else: + raise RuntimeError("invalid unet style, should be split, single or multihead") + + self.unet_cell_ind = UNet( + in_channels=1, + num_fmaps=num_fmaps[0], + fmap_inc_factor=config.model.fmap_inc_factors, + downsample_factors=config.model.downsample_factors, + kernel_size_down=config.model.kernel_size_down, + kernel_size_up=config.model.kernel_size_up, + constant_upsample=config.model.constant_upsample, + upsampling=config.model.upsampling, + num_heads=num_heads, + ) + + if config.model.unet_style == "split" and \ + not self.config.model.train_only_cell_indicator: + self.unet_par_vec = UNet( + in_channels=1, + num_fmaps=num_fmaps[1], + fmap_inc_factor=config.model.fmap_inc_factors, + downsample_factors=config.model.downsample_factors, + kernel_size_down=config.model.kernel_size_down, + kernel_size_up=config.model.kernel_size_up, + constant_upsample=config.model.constant_upsample, + upsampling=config.model.upsampling, + num_heads=1, + ) + + self.cell_indicator_batched = ConvPass(num_fmaps[0], 1, [[1, 1, 1]], + activation='Sigmoid') + self.parent_vectors_batched = ConvPass(num_fmaps[1], 3, [[1, 1, 1]], + activation=None) + + self.nms = torch.nn.MaxPool3d(config.model.nms_window_shape, stride=1, padding=0) + + def init_layers(self): + # the default init in pytorch is a bit strange + # https://pytorch.org/docs/stable/_modules/torch/nn/modules/conv.html#Conv2d + # some modified (for backwards comp) version of kaiming + # breaks training, cell_ind -> 0 + # activation func relu + def init_weights(m): + # print("init", m) + if isinstance(m, torch.nn.Conv3d): + # print("init") + # torch.nn.init.xavier_uniform(m.weight) + torch.nn.init.kaiming_normal_(m.weight, nonlinearity='relu') + m.bias.data.fill_(0.0) + # activation func sigmoid + def init_weights_sig(m): + if isinstance(m, torch.nn.Conv3d): + # torch.nn.init.xavier_uniform(m.weight) + torch.nn.init.xavier_uniform_(m.weight) + m.bias.data.fill_(0.0) + + logger.info("initializing model..") + self.apply(init_weights) + self.cell_indicator_batched.apply(init_weights_sig) + if not self.config.model.train_only_cell_indicator: + self.parent_vectors_batched.apply(init_weights) + + def inout_shapes(self, device): + logger.info("getting train/test output shape by running model twice") + input_shape_predict = self.config.model.predict_input_shape + self.eval() + with torch.no_grad(): + trial_run_predict = self.forward(torch.zeros(input_shape_predict, + dtype=torch.float32).to(device)) + self.train() + logger.info("test done") + if self.config.model.train_only_cell_indicator: + _, _, trial_max_predict = trial_run_predict + else: + _, _, trial_max_predict, _ = trial_run_predict + output_shape_predict = trial_max_predict.size() + net_config = { + 'input_shape': input_shape_predict, + 'output_shape_2': output_shape_predict + } + with open(os.path.join(self.config.general.setup_dir, + 'test_net_config.json'), 'w') as f: + json.dump(net_config, f) + + input_shape = self.config.model.train_input_shape + trial_run = self.forward(torch.zeros(input_shape, dtype=torch.float32).to(device)) + if self.config.model.train_only_cell_indicator: + trial_ci, _, trial_max = trial_run + else: + trial_ci, _, trial_max, _ = trial_run + output_shape_1 = trial_ci.size() + output_shape_2 = trial_max.size() + logger.info("train done") + net_config = { + 'input_shape': input_shape, + 'output_shape_1': output_shape_1, + 'output_shape_2': output_shape_2 + } + with open(os.path.join(self.config.general.setup_dir, + 'train_net_config.json'), 'w') as f: + json.dump(net_config, f) + return input_shape, output_shape_1, output_shape_2 + + + def forward(self, raw): + if self.config.model.latent_temp_conv: + raw = torch.reshape(raw, [raw.size()[0], 1] + list(raw.size())[1:]) + else: + raw = torch.reshape(raw, [1, 1] + list(raw.size())) + model_out_1 = self.unet_cell_ind(raw) + if self.config.model.unet_style != "multihead" or \ + self.config.model.train_only_cell_indicator: + model_out_1 = [model_out_1] + cell_indicator_batched = self.cell_indicator_batched(model_out_1[0]) + output_shape_1 = list(cell_indicator_batched.size())[1:] + cell_indicator = torch.reshape(cell_indicator_batched, output_shape_1) + + if self.config.model.unet_style == "single": + parent_vectors_batched = self.parent_vectors_batched(model_out_1[0]) + parent_vectors = torch.reshape(parent_vectors_batched, [3] + output_shape_1) + elif self.config.model.unet_style == "split" and \ + not self.config.model.train_only_cell_indicator: + model_par_vec = self.unet_par_vec(raw) + parent_vectors_batched = self.parent_vectors_batched(model_par_vec) + parent_vectors = torch.reshape(parent_vectors_batched, [3] + output_shape_1) + else: # self.config.model.unet_style == "multihead" + parent_vectors_batched = self.parent_vectors_batched(model_out_1[1]) + parent_vectors = torch.reshape(parent_vectors_batched, [3] + output_shape_1) + + maxima = self.nms(cell_indicator_batched) + if not self.training: + factor_product = None + for factor in self.config.model.downsample_factors: + if factor_product is None: + factor_product = list(factor) + else: + factor_product = list( + f*ff + for f, ff in zip(factor, factor_product)) + maxima = crop_to_factor( + maxima, + factor=factor_product, + kernel_sizes=[[1, 1, 1]]) + + output_shape_2 = tuple(list(maxima.size())[1:]) + maxima = torch.reshape(maxima, output_shape_2) + + if not self.training: + cell_indicator = crop(cell_indicator, output_shape_2) + maxima = torch.eq(maxima, cell_indicator) + if not self.config.model.train_only_cell_indicator: + parent_vectors = crop(parent_vectors, output_shape_2) + else: + cell_indicator_cropped = crop(cell_indicator, output_shape_2) + maxima = torch.eq(maxima, cell_indicator_cropped) + + if self.config.model.latent_temp_conv: + raw_cropped = crop(raw[raw.size()[0]//2], output_shape_2) + else: + raw_cropped = crop(raw, output_shape_2) + raw_cropped = torch.reshape(raw_cropped, output_shape_2) + # print(torch.min(raw), torch.max(raw)) + # print(torch.min(raw_cropped), torch.max(raw_cropped)) + + if self.config.model.train_only_cell_indicator: + return cell_indicator, maxima, raw_cropped + else: + return cell_indicator, maxima, raw_cropped, parent_vectors diff --git a/linajea/training/train.py b/linajea/training/train.py new file mode 100644 index 0000000..19c1afa --- /dev/null +++ b/linajea/training/train.py @@ -0,0 +1,701 @@ +"""Script for training process + +Create model and train +""" +from __future__ import print_function +import warnings +warnings.filterwarnings("once", category=FutureWarning) + +import argparse +import logging +import time +import os +import sys + +import numpy as np +import torch +from tqdm import tqdm +from tqdm.contrib.logging import logging_redirect_tqdm + +import gunpowder as gp + +from linajea.gunpowder_nodes import (TracksSource, AddMovementVectors, + ShiftAugment, ShuffleChannels, Clip, + NoOp, NormalizeMinMax, NormalizeMeanStd, + NormalizeMedianMad, + RandomLocationExcludeTime) +from linajea.config import (load_config, + maybe_fix_config_paths_to_machine_and_load, + TrackingConfig) + + +from . import torch_model +from . import torch_loss +from .utils import (get_latest_checkpoint, + Cast) + + +logger = logging.getLogger(__name__) + + +def train(config): + """Main train function + + All information is taken from config object (what model architecture, + optimizer, loss, data, augmentation etc to use) + Train for config.train.max_iterations steps. + Optionally compute interleaved validation statistics. + + Args + ---- + config: TrackingConfig + Tracking configuration object, has to contain at least model, + train and data configuration, optionally augment + """ + # Get the latest checkpoint + checkpoint_basename = os.path.join(config.general.setup_dir, 'train_net') + latest_checkpoint, trained_until = get_latest_checkpoint(checkpoint_basename) + # training already done? + if trained_until >= config.train.max_iterations: + return + + anchor = gp.ArrayKey('ANCHOR') + raw = gp.ArrayKey('RAW') + raw_cropped = gp.ArrayKey('RAW_CROPPED') + tracks = gp.PointsKey('TRACKS') + center_tracks = gp.PointsKey('CENTER_TRACKS') + cell_indicator = gp.ArrayKey('CELL_INDICATOR') + cell_center = gp.ArrayKey('CELL_CENTER') + pred_cell_indicator = gp.ArrayKey('PRED_CELL_INDICATOR') + maxima = gp.ArrayKey('MAXIMA') + grad_cell_indicator = gp.ArrayKey('GRAD_CELL_INDICATOR') + if not config.model.train_only_cell_indicator: + movement_vectors = gp.ArrayKey('MOVEMENT_VECTORS') + pred_movement_vectors = gp.ArrayKey('PRED_MOVEMENT_VECTORS') + cell_mask = gp.ArrayKey('CELL_MASK') + grad_movement_vectors = gp.ArrayKey('GRAD_MOVEMENT_VECTORS') + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + torch.backends.cudnn.benchmark = True + + model = torch_model.UnetModelWrapper(config, trained_until) + model.init_layers() + try: + model = model.to(device) + except RuntimeError as e: + raise RuntimeError( + "Failed to move model to device. If you are using a child process " + "to run your model, maybe you already initialized CUDA by sending " + "your model to device in the main process." + ) from e + + input_shape, output_shape_1, output_shape_2 = model.inout_shapes(device=device) + logger.debug("Model: %s", model) + + voxel_size = gp.Coordinate(config.train_data.data_sources[0].voxel_size) + input_size = gp.Coordinate(input_shape) * voxel_size + output_size_1 = gp.Coordinate(output_shape_1) * voxel_size + output_size_2 = gp.Coordinate(output_shape_2) * voxel_size + center_size = gp.Coordinate(output_shape_2) * voxel_size + # add a buffer in time to avoid choosing a random location based on points + # in only one frame, because then all points are rejected as being on the + # lower boundary of that frame + center_size = center_size + gp.Coordinate((1, 0, 0, 0)) + logger.debug("Center size: {}".format(center_size)) + logger.debug("Output size 1: {}".format(output_size_1)) + logger.debug("Voxel size: {}".format(voxel_size)) + + request = gp.BatchRequest() + request.add(raw, input_size) + request.add(tracks, output_size_1) + request.add(center_tracks, center_size) + request.add(cell_indicator, output_size_1) + request.add(cell_center, output_size_1) + request.add(anchor, output_size_2) + request.add(raw_cropped, output_size_2) + request.add(maxima, output_size_2) + if not config.model.train_only_cell_indicator: + request.add(movement_vectors, output_size_1) + request.add(cell_mask, output_size_1) + logger.debug("REQUEST: %s" % str(request)) + snapshot_request = gp.BatchRequest({ + raw: request[raw], + pred_cell_indicator: request[movement_vectors], + grad_cell_indicator: request[movement_vectors] + }) + snapshot_request.add(pred_cell_indicator, output_size_1) + snapshot_request.add(raw_cropped, output_size_2) + snapshot_request.add(maxima, output_size_2) + if not config.model.train_only_cell_indicator: + snapshot_request.add(pred_movement_vectors, output_size_1) + snapshot_request.add(grad_movement_vectors, output_size_1) + logger.debug("Snapshot request: %s" % str(snapshot_request)) + + + train_sources = get_sources(config, raw, anchor, tracks, center_tracks, + config.train_data.data_sources, val=False) + + # Do interleaved validation? + if config.train.val_log_step is not None: + val_sources = get_sources(config, raw, anchor, tracks, center_tracks, + config.validate_data.data_sources, val=True) + + # set up pipeline: + # load data source and + # choose augmentations depending on config + augment = config.train.augment + train_pipeline = ( + tuple(train_sources) + + gp.RandomProvider() + + + (gp.ElasticAugment( + augment.elastic.control_point_spacing, + augment.elastic.jitter_sigma, + [augment.elastic.rotation_min*np.pi/180.0, + augment.elastic.rotation_max*np.pi/180.0], + rotation_3d=augment.elastic.rotation_3d, + subsample=augment.elastic.subsample, + use_fast_points_transform=augment.elastic.use_fast_points_transform, + spatial_dims=3, + temporal_dim=True) \ + if augment.elastic is not None else NoOp()) + + + (ShiftAugment( + prob_slip=augment.shift.prob_slip, + prob_shift=augment.shift.prob_shift, + sigma=augment.shift.sigma, + shift_axis=0) \ + if augment.shift is not None else NoOp()) + + + (ShuffleChannels(raw) \ + if augment.shuffle_channels else NoOp()) + + + (gp.SimpleAugment( + mirror_only=augment.simple.mirror, + transpose_only=augment.simple.transpose) \ + if augment.simple is not None else NoOp()) + + + (gp.ZoomAugment( + factor_min=augment.zoom.factor_min, + factor_max=augment.zoom.factor_max, + spatial_dims=augment.zoom.spatial_dims, + order={raw: 1, + }) \ + if augment.zoom is not None else NoOp()) + + + (gp.NoiseAugment( + raw, + mode='gaussian', + var=augment.noise_gaussian.var, + clip=False, + check_val_range=False) \ + if augment.noise_gaussian is not None else NoOp()) + + + (gp.NoiseAugment( + raw, + mode='speckle', + var=augment.noise_speckle.var, + clip=False, + check_val_range=False) \ + if augment.noise_speckle is not None else NoOp()) + + + (gp.NoiseAugment( + raw, + mode='s&p', + amount=augment.noise_saltpepper.amount, + clip=False, + check_val_range=False) \ + if augment.noise_saltpepper is not None else NoOp()) + + + (gp.HistogramAugment( + raw, + # raw_tmp, + range_low=augment.histogram.range_low, + range_high=augment.histogram.range_high, + z_section_wise=False) \ + if augment.histogram is not None else NoOp()) + + + (gp.IntensityAugment( + raw, + scale_min=augment.intensity.scale[0], + scale_max=augment.intensity.scale[1], + shift_min=augment.intensity.shift[0], + shift_max=augment.intensity.shift[1], + z_section_wise=False, + clip=False) \ + if augment.intensity is not None else NoOp()) + + + (AddMovementVectors( + tracks, + movement_vectors, + cell_mask, + array_spec=gp.ArraySpec(voxel_size=voxel_size), + radius=config.train.object_radius, + move_radius=config.train.move_radius, + dense=not config.general.sparse) \ + if not config.model.train_only_cell_indicator else NoOp()) + + + (gp.Reject( + ensure_nonempty=tracks, + mask=cell_mask, + min_masked=0.0001, + ) \ + if config.general.sparse else NoOp()) + + + gp.RasterizeGraph( + tracks, + cell_indicator, + array_spec=gp.ArraySpec(voxel_size=voxel_size), + settings=gp.RasterizationSettings( + radius=config.train.rasterize_radius, + mode='peak')) + + + gp.RasterizeGraph( + tracks, + cell_center, + array_spec=gp.ArraySpec(voxel_size=voxel_size), + settings=gp.RasterizationSettings( + radius=(0.1,) + voxel_size[1:], + mode='point')) + + + + (gp.PreCache( + cache_size=config.train.cache_size, + num_workers=config.train.job.num_workers) \ + if config.train.job.num_workers > 1 else NoOp()) + ) + + # set up optional validation path without augmentations + if config.train.val_log_step is not None: + val_pipeline = ( + tuple(val_sources) + + gp.RandomProvider() + + + (AddMovementVectors( + tracks, + movement_vectors, + cell_mask, + array_spec=gp.ArraySpec(voxel_size=voxel_size), + radius=config.train.object_radius, + move_radius=config.train.move_radius, + dense=not config.general.sparse) \ + if not config.model.train_only_cell_indicator else NoOp()) + + + (gp.Reject( + ensure_nonempty=tracks, + mask=cell_mask, + min_masked=0.0001, + reject_probability=augment.reject_empty_prob + ) \ + if config.general.sparse else NoOp()) + + + gp.Reject( + ensure_nonempty=center_tracks, + # always reject emtpy batches in validation branch + reject_probability=1.0 + ) + + + gp.RasterizePoints( + tracks, + cell_indicator, + array_spec=gp.ArraySpec(voxel_size=voxel_size), + settings=gp.RasterizationSettings( + radius=config.train.rasterize_radius, + mode='peak')) + + + gp.RasterizePoints( + tracks, + cell_center, + array_spec=gp.ArraySpec(voxel_size=voxel_size), + settings=gp.RasterizationSettings( + radius=(0.1,) + voxel_size[1:], + mode='point')) + + + + (gp.PreCache( + cache_size=config.train.cache_size, + num_workers=1) \ + if config.train.job.num_workers > 1 else NoOp()) + ) + + if config.train.val_log_step is not None: + pipeline = ( + (train_pipeline, val_pipeline) + + gp.TrainValProvider(step=config.train.val_log_step, + init_step=trained_until)) + else: + pipeline = train_pipeline + + + inputs={ + 'raw': raw, + } + if not config.model.train_only_cell_indicator: + inputs['cell_mask'] = cell_mask + inputs['gt_movement_vectors'] = movement_vectors + + outputs={ + 0: pred_cell_indicator, + 1: maxima, + 2: raw_cropped, + } + if not config.model.train_only_cell_indicator: + outputs[3] = pred_movement_vectors + + loss_inputs={ + 'gt_cell_indicator': cell_indicator, + 'cell_indicator': pred_cell_indicator, + 'maxima': maxima, + 'gt_cell_center': cell_center, + } + if not config.model.train_only_cell_indicator: + loss_inputs['cell_mask'] = cell_mask + loss_inputs['gt_movement_vectors'] = movement_vectors + loss_inputs['movement_vectors'] = pred_movement_vectors + + gradients = { + 0: grad_cell_indicator, + } + if not config.model.train_only_cell_indicator: + gradients[3] = grad_movement_vectors + + snapshot_datasets = { + raw: 'volumes/raw', + anchor: 'volumes/anchor', + raw_cropped: 'volumes/raw_cropped', + cell_indicator: 'volumes/cell_indicator', + cell_center: 'volumes/cell_center', + + pred_cell_indicator: 'volumes/pred_cell_indicator', + maxima: 'volumes/maxima', + grad_cell_indicator: 'volumes/grad_cell_indicator', + } + if not config.model.train_only_cell_indicator: + snapshot_datasets[cell_mask] = 'volumes/cell_mask' + snapshot_datasets[movement_vectors] = 'volumes/movement_vectors' + snapshot_datasets[pred_movement_vectors] = 'volumes/pred_movement_vectors' + snapshot_datasets[grad_movement_vectors] = 'volumes/grad_movement_vectors' + + if logger.isEnabledFor(logging.DEBUG): + logger.debug("requires_grad enabled for:") + for name, param in model.named_parameters(): + if param.requires_grad: + logger.debug("%s", name) + + # create optimizer + opt = getattr(torch.optim, config.optimizerTorch.optimizer)( + model.parameters(), **config.optimizerTorch.get_kwargs()) + + # if new training, save initial state to disk + if trained_until == 0: + torch.save( + { + "model_state_dict": model.state_dict(), + "optimizer_state_dict": opt.state_dict(), + }, + os.path.join(config.general.setup_dir, "train_net_checkpoint_0")) + + # create loss object + loss = torch_loss.LossWrapper(config, current_step=trained_until) + + # and add training gunpowder node + pipeline = ( + pipeline + + gp.torch.Train( + model=model, + loss=loss, + optimizer=opt, + checkpoint_basename=os.path.join(config.general.setup_dir, 'train_net'), + inputs=inputs, + outputs=outputs, + loss_inputs=loss_inputs, + gradients=gradients, + log_dir=os.path.join(config.general.setup_dir, "train"), + val_log_step=config.train.val_log_step, + use_auto_mixed_precision=config.train.use_auto_mixed_precision, + use_swa=config.train.use_swa, + swa_every_it=config.train.swa_every_it, + swa_start_it=config.train.swa_start_it, + swa_freq_it=config.train.swa_freq_it, + use_grad_norm=config.train.use_grad_norm, + save_every=config.train.checkpoint_stride) + + + # visualize + gp.Snapshot(snapshot_datasets, + output_dir=os.path.join(config.general.setup_dir, 'snapshots'), + output_filename='snapshot_{iteration}.hdf', + additional_request=snapshot_request, + every=config.train.snapshot_stride, + dataset_dtypes={ + maxima: np.float32 + }) + + gp.PrintProfilingStats(every=config.train.profiling_stride) + ) + + # finalize pipeline and start training + with gp.build(pipeline): + + logger.info("Starting training...") + with logging_redirect_tqdm(): + for i in tqdm(range(trained_until, config.train.max_iterations)): + start = time.time() + pipeline.request_batch(request) + time_of_iteration = time.time() - start + + logger.info( + "Batch: iteration=%d, time=%f", + i, time_of_iteration) + + +def normalize(file_source, config, raw, data_config=None): + """Add data normalization node to pipeline + + Notes + ----- + Which normalization method should be used? + None/default: + [0,1] based on data type + minmax: + normalize such that lower bound is at 0 and upper bound at 1 + clipping is less strict, some data might be outside of range + percminmax: + use precomputed percentile values for minmax normalization; + precomputed values are stored in data_config file that has to + be supplied; set perc_min/max to tag to be used + mean/median + normalize such that mean/median is at 0 and 1 std/mad is at -+1 + set perc_min/max tags for clipping beforehand + """ + if config.train.normalization is None or \ + config.train.normalization.type == 'default': + logger.info("default normalization") + file_source = file_source + \ + gp.Normalize(raw, + factor=1.0/np.iinfo(data_config['stats']['dtype']).max + if data_config is not None else None) + elif config.train.normalization.type == 'minmax': + mn = config.train.normalization.norm_bounds[0] + mx = config.train.normalization.norm_bounds[1] + logger.info("minmax normalization %s %s", mn, mx) + file_source = file_source + \ + Clip(raw, mn=mn/2, mx=mx*2) + \ + NormalizeMinMax(raw, mn=mn, mx=mx, interpolatable=False) + elif config.train.normalization.type == 'percminmax': + mn = data_config['stats'][config.train.normalization.perc_min] + mx = data_config['stats'][config.train.normalization.perc_max] + logger.info("perc minmax normalization %s %s", mn, mx) + file_source = file_source + \ + Clip(raw, mn=mn/2, mx=mx*2) + \ + NormalizeMinMax(raw, mn=mn, mx=mx) + elif config.train.normalization.type == 'mean': + mean = data_config['stats']['mean'] + std = data_config['stats']['std'] + mn = data_config['stats'][config.train.normalization.perc_min] + mx = data_config['stats'][config.train.normalization.perc_max] + logger.info("mean normalization %s %s %s %s", mean, std, mn, mx) + file_source = file_source + \ + Clip(raw, mn=mn, mx=mx) + \ + NormalizeMeanStd(raw, mean=mean, std=std) + elif config.train.normalization.type == 'median': + median = data_config['stats']['median'] + mad = data_config['stats']['mad'] + mn = data_config['stats'][config.train.normalization.perc_min] + mx = data_config['stats'][config.train.normalization.perc_max] + logger.info("median normalization %s %s %s %s", median, mad, mn, mx) + file_source = file_source + \ + Clip(raw, mn=mn, mx=mx) + \ + NormalizeMedianMad(raw, median=median, mad=mad) + else: + raise RuntimeError("invalid normalization method %s", + config.train.normalization.type) + return file_source + + +def get_sources(config, raw, anchor, tracks, center_tracks, data_sources, + val=False): + """Create gunpowder source nodes for each data source in config + + Args + ---- + config: TrackingConfig + Configuration object + raw: gp.Array + Raw data will be stored here. + anchor: gp.Array + Ignore this. + tracks: gp.Graph + Tracks/points will be stored here + center_tracks: gp.Graph + Used if increased division sampling is used; division points + will be stored here + data_sources: + List of data sources to use; set this to correct + config.data.data_sources object depending on if train or val + data should be used. + val: + Set to true if val data is used. + """ + sources = [] + for ds in data_sources: + d = ds.datafile.filename + voxel_size = gp.Coordinate(ds.voxel_size) + if not os.path.isdir(d): + logger.info("trimming path %s", d) + d = os.path.dirname(d) + logger.info("loading data %s (val: %s)", d, val) + # set files to use, use data_config.toml if it exists + # otherwise check for information in config object + if os.path.isfile(os.path.join(d, "data_config.toml")): + data_config = load_config(os.path.join(d, "data_config.toml")) + try: + filename_data = os.path.join( + d, data_config['general']['data_file']) + except KeyError: + filename_data = os.path.join( + d, data_config['general']['zarr_file']) + filename_tracks = os.path.join( + d, data_config['general']['tracks_file']) + if config.train.augment.divisions != 0.0: + try: + filename_divisions = os.path.join( + d, data_config['general']['divisions_file']) + except KeyError: + logger.warning("Cannot find divisions_file in data_config, " + "falling back to using tracks_file" + "(usually ok unless they are not included and " + "there is a separate file containing the " + "divisions)") + filename_divisions = os.path.join( + d, data_config['general']['tracks_file']) + filename_daughters = os.path.join( + d, data_config['general']['daughter_cells_file']) + else: + data_config = None + filename_data = d + filename_tracks = ds.tracksfile + filename_divisions = ds.divisionsfile + filename_daughters = ds.daughtersfile + logger.info("creating source: %s (%s, %s, %s), divisions?: %s", + filename_data, ds.datafile.group, + filename_tracks, filename_daughters, + config.train.augment.divisions) + limit_to_roi = gp.Roi(offset=ds.roi.offset, shape=ds.roi.shape) + logger.info("limiting to roi: %s", limit_to_roi) + + datasets = { + raw: ds.datafile.group, + anchor: ds.datafile.group + } + array_specs = { + raw: gp.ArraySpec( + interpolatable=True, + voxel_size=voxel_size), + anchor: gp.ArraySpec( + interpolatable=False, + voxel_size=voxel_size) + } + file_source = gp.ZarrSource( + filename_data, + datasets=datasets, + nested="nested" in ds.datafile.group, + array_specs=array_specs) + + file_source = file_source + \ + gp.Crop(raw, limit_to_roi) + file_source = normalize(file_source, config, raw, data_config) + + file_source = file_source + \ + gp.Pad(raw, None) + + # if some frames should not be sampled from + if ds.exclude_times: + random_location = RandomLocationExcludeTime + args = [raw, ds.exclude_times] + else: + random_location = gp.RandomLocation + args = [raw] + + track_source = ( + merge_sources( + file_source, + tracks, + center_tracks, + filename_tracks, + filename_tracks, + limit_to_roi, + use_radius=config.train.use_radius) + + random_location( + *args, + ensure_nonempty=center_tracks, + p_nonempty=config.train.augment.reject_empty_prob, + point_balance_radius=config.train.augment.point_balance_radius) + ) + + # if division nodes should be sampled more often + if config.train.augment.divisions != 0.0: + div_source = ( + merge_sources( + file_source, + tracks, + center_tracks, + filename_divisions, + filename_daughters, + limit_to_roi, + use_radius=config.train.use_radius) + + random_location( + *args, + ensure_nonempty=center_tracks, + p_nonempty=config.train.augment.reject_empty_prob, + point_balance_radius=config.train.augment.point_balance_radius) + ) + + track_source = (track_source, div_source) + \ + gp.RandomProvider(probabilities=[ + 1.0-config.train.augment.divisions, + config.train.augment.divisions]) + + sources.append(track_source) + + return sources + + + +def merge_sources( + raw, + tracks, + center_tracks, + track_file, + center_cell_file, + roi, + scale=1.0, + use_radius=False + ): + """Create two Track/Point sources, one with a smaller Roi. + Goal: During sampling a random location will be selected such that + at least one point is within the smaller Roi, but all points within + the larger Roi will be included. + """ + return ( + (raw, + # tracks + TracksSource( + track_file, + tracks, + points_spec=gp.PointsSpec(roi=roi), + scale=scale, + use_radius=use_radius), + # center tracks + TracksSource( + center_cell_file, + center_tracks, + points_spec=gp.PointsSpec(roi=roi), + scale=scale, + use_radius=use_radius), + ) + + gp.MergeProvider() + + # not None padding works in combination with ensure_nonempty in + # random_location as always a random point is picked and the roi + # shifted such that that point is inside + gp.Pad(tracks, gp.Coordinate((0, 500, 500, 500))) + + gp.Pad(center_tracks, gp.Coordinate((0, 500, 500, 500))) + ) diff --git a/linajea/training/utils.py b/linajea/training/utils.py new file mode 100644 index 0000000..0cd2c11 --- /dev/null +++ b/linajea/training/utils.py @@ -0,0 +1,136 @@ +import copy +import glob +import math +import re + +import numpy as np + +import gunpowder as gp + + +def get_latest_checkpoint(basename): + + def atoi(text): + return int(text) if text.isdigit() else text + + def natural_keys(text): + return [ atoi(c) for c in re.split(r'(\d+)', text) ] + + checkpoints = glob.glob(basename + '_checkpoint_*') + checkpoints.sort(key=natural_keys) + + if len(checkpoints) > 0: + checkpoint = checkpoints[-1] + iteration = int(checkpoint.split('_')[-1].split('.')[0]) + return checkpoint, iteration + + return None, 0 + + +class Cast(gp.BatchFilter): + + def __init__( + self, + array, + dtype=np.float32): + + self.array = array + self.dtype = dtype + + def setup(self): + self.enable_autoskip() + array_spec = copy.deepcopy(self.spec[self.array]) + array_spec.dtype = self.dtype + self.updates(self.array, array_spec) + + def prepare(self, request): + deps = gp.BatchRequest() + deps[self.array] = request[self.array] + deps[self.array].dtype = None + return deps + + def process(self, batch, request): + if self.array not in batch.arrays: + return + + array = batch.arrays[self.array] + array.spec.dtype = self.dtype + array.data = array.data.astype(self.dtype) + + +def crop(x, shape): + '''Center-crop x to match spatial dimensions given by shape.''' + + dims = len(x.size()) - len(shape) + x_target_size = x.size()[:dims] + shape + + offset = tuple( + (a - b)//2 + for a, b in zip(x.size(), x_target_size)) + + slices = tuple( + slice(o, o + s) + for o, s in zip(offset, x_target_size)) + + # print(x.size(), shape, slices) + return x[slices] + + +def crop_to_factor(x, factor, kernel_sizes): + '''Crop feature maps to ensure translation equivariance with stride of + upsampling factor. This should be done right after upsampling, before + application of the convolutions with the given kernel sizes. + + The crop could be done after the convolutions, but it is more efficient + to do that before (feature maps will be smaller). + ''' + + shape = x.size() + dims = len(x.size()) - 2 + spatial_shape = shape[-dims:] + + # the crop that will already be done due to the convolutions + convolution_crop = tuple( + sum(ks[d] - 1 for ks in kernel_sizes) + for d in range(dims) + ) + + # we need (spatial_shape - convolution_crop) to be a multiple of + # factor, i.e.: + # + # (s - c) = n*k + # + # we want to find the largest n for which s' = n*k + c <= s + # + # n = floor((s - c)/k) + # + # this gives us the target shape s' + # + # s' = n*k + c + + ns = ( + int(math.floor(float(s - c)/f)) + for s, c, f in zip(spatial_shape, convolution_crop, factor) + ) + target_spatial_shape = tuple( + n*f + c + for n, c, f in zip(ns, convolution_crop, factor) + ) + + if target_spatial_shape != spatial_shape: + + assert all(( + (t > c) for t, c in zip( + target_spatial_shape, + convolution_crop)) + ), \ + "Feature map with shape %s is too small to ensure " \ + "translation equivariance with factor %s and following " \ + "convolutions %s" % ( + shape, + factor, + kernel_sizes) + + return crop(x, target_spatial_shape) + + return x From fd0fd38d9e2cdb61c885bf7b751651f06b1916e4 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 10:05:10 -0400 Subject: [PATCH 193/263] add run/tutorial scripts --- linajea/run_scripts/run.ipynb | 1619 +-------------------------------- 1 file changed, 24 insertions(+), 1595 deletions(-) diff --git a/linajea/run_scripts/run.ipynb b/linajea/run_scripts/run.ipynb index b346e8b..1d1b0f4 100644 --- a/linajea/run_scripts/run.ipynb +++ b/linajea/run_scripts/run.ipynb @@ -24,7 +24,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "e5c7284e", "metadata": {}, "outputs": [], @@ -52,7 +52,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "dffaf22c", "metadata": {}, "outputs": [], @@ -77,7 +77,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "a28ca24d", "metadata": {}, "outputs": [], @@ -104,331 +104,10 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "f2ae694b", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-24 06:01:51,511 linajea.training.torch_model INFO initializing model..\n", - "2022-06-24 06:01:54,366 linajea.training.torch_model INFO getting train/test output shape by running model twice\n", - "2022-06-24 06:01:58,372 linajea.training.torch_model INFO test done\n", - "2022-06-24 06:01:58,813 linajea.training.torch_model INFO train done\n", - "2022-06-24 06:01:58,819 linajea.training.train INFO Model: UnetModelWrapper(\n", - " (unet_cell_ind): UNet(\n", - " (l_conv): ModuleList(\n", - " (0): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv4d(\n", - " (conv3d_layers): ModuleList(\n", - " (0): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (2): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " )\n", - " )\n", - " (1): ReLU()\n", - " (2): Conv4d(\n", - " (conv3d_layers): ModuleList(\n", - " (0): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (2): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " )\n", - " )\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " (1): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv4d(\n", - " (conv3d_layers): ModuleList(\n", - " (0): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (2): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " )\n", - " )\n", - " (1): ReLU()\n", - " (2): Conv3d(48, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " (2): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(48, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): ReLU()\n", - " (2): Conv3d(192, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " (3): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(192, 768, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): ReLU()\n", - " (2): Conv3d(768, 768, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " )\n", - " (l_down): ModuleList(\n", - " (0): Downsample(\n", - " (down): MaxPool3d(kernel_size=[1, 2, 2], stride=[1, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", - " )\n", - " (1): Downsample(\n", - " (down): MaxPool3d(kernel_size=[1, 2, 2], stride=[1, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", - " )\n", - " (2): Downsample(\n", - " (down): MaxPool3d(kernel_size=[2, 2, 2], stride=[2, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", - " )\n", - " )\n", - " (r_up): ModuleList(\n", - " (0): ModuleList(\n", - " (0): Upsample(\n", - " (up): Sequential(\n", - " (0): ConvTranspose3d(48, 48, kernel_size=(1, 2, 2), stride=(1, 2, 2), groups=48)\n", - " (1): Conv3d(48, 12, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", - " )\n", - " (activation): ReLU()\n", - " )\n", - " (1): Upsample(\n", - " (up): Sequential(\n", - " (0): ConvTranspose3d(192, 192, kernel_size=(1, 2, 2), stride=(1, 2, 2), groups=192)\n", - " (1): Conv3d(192, 48, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", - " )\n", - " (activation): ReLU()\n", - " )\n", - " (2): Upsample(\n", - " (up): Sequential(\n", - " (0): ConvTranspose3d(768, 768, kernel_size=(2, 2, 2), stride=(2, 2, 2), groups=768)\n", - " (1): Conv3d(768, 192, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", - " )\n", - " (activation): ReLU()\n", - " )\n", - " )\n", - " )\n", - " (r_conv): ModuleList(\n", - " (0): ModuleList(\n", - " (0): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(24, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): ReLU()\n", - " (2): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " (1): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(96, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): ReLU()\n", - " (2): Conv3d(48, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " (2): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(384, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): ReLU()\n", - " (2): Conv3d(192, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " )\n", - " )\n", - " )\n", - " (unet_par_vec): UNet(\n", - " (l_conv): ModuleList(\n", - " (0): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv4d(\n", - " (conv3d_layers): ModuleList(\n", - " (0): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (2): Conv3d(1, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " )\n", - " )\n", - " (1): ReLU()\n", - " (2): Conv4d(\n", - " (conv3d_layers): ModuleList(\n", - " (0): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (2): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " )\n", - " )\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " (1): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv4d(\n", - " (conv3d_layers): ModuleList(\n", - " (0): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (2): Conv3d(12, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " )\n", - " )\n", - " (1): ReLU()\n", - " (2): Conv3d(48, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " (2): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(48, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): ReLU()\n", - " (2): Conv3d(192, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " (3): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(192, 768, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): ReLU()\n", - " (2): Conv3d(768, 768, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " )\n", - " (l_down): ModuleList(\n", - " (0): Downsample(\n", - " (down): MaxPool3d(kernel_size=[1, 2, 2], stride=[1, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", - " )\n", - " (1): Downsample(\n", - " (down): MaxPool3d(kernel_size=[1, 2, 2], stride=[1, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", - " )\n", - " (2): Downsample(\n", - " (down): MaxPool3d(kernel_size=[2, 2, 2], stride=[2, 2, 2], padding=0, dilation=1, ceil_mode=False)\n", - " )\n", - " )\n", - " (r_up): ModuleList(\n", - " (0): ModuleList(\n", - " (0): Upsample(\n", - " (up): Sequential(\n", - " (0): ConvTranspose3d(48, 48, kernel_size=(1, 2, 2), stride=(1, 2, 2), groups=48)\n", - " (1): Conv3d(48, 12, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", - " )\n", - " (activation): ReLU()\n", - " )\n", - " (1): Upsample(\n", - " (up): Sequential(\n", - " (0): ConvTranspose3d(192, 192, kernel_size=(1, 2, 2), stride=(1, 2, 2), groups=192)\n", - " (1): Conv3d(192, 48, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", - " )\n", - " (activation): ReLU()\n", - " )\n", - " (2): Upsample(\n", - " (up): Sequential(\n", - " (0): ConvTranspose3d(768, 768, kernel_size=(2, 2, 2), stride=(2, 2, 2), groups=768)\n", - " (1): Conv3d(768, 192, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", - " )\n", - " (activation): ReLU()\n", - " )\n", - " )\n", - " )\n", - " (r_conv): ModuleList(\n", - " (0): ModuleList(\n", - " (0): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(24, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): ReLU()\n", - " (2): Conv3d(12, 12, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " (1): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(96, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): ReLU()\n", - " (2): Conv3d(48, 48, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " (2): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(384, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (1): ReLU()\n", - " (2): Conv3d(192, 192, kernel_size=(3, 3, 3), stride=(1, 1, 1))\n", - " (3): ReLU()\n", - " )\n", - " )\n", - " )\n", - " )\n", - " )\n", - " (cell_indicator_batched): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(12, 1, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", - " (1): Sigmoid()\n", - " )\n", - " )\n", - " (parent_vectors_batched): ConvPass(\n", - " (layers): ModuleList(\n", - " (0): Conv3d(12, 3, kernel_size=(1, 1, 1), stride=(1, 1, 1))\n", - " )\n", - " )\n", - " (nms): MaxPool3d(kernel_size=[3, 9, 9], stride=1, padding=0, dilation=1, ceil_mode=False)\n", - ")\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-24 06:01:58,821 linajea.training.train INFO Center size: (2, 30, 52, 52)\n", - "2022-06-24 06:01:58,821 linajea.training.train INFO Output size 1: (1, 40, 60, 60)\n", - "2022-06-24 06:01:58,821 linajea.training.train INFO Voxel size: (1, 5, 1, 1)\n", - "2022-06-24 06:01:58,824 linajea.training.train INFO REQUEST: \n", - "\tRAW: ROI: [0:7, 0:200, 0:148, 0:148] (7, 200, 148, 148), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tCELL_INDICATOR: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tCELL_CENTER: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tANCHOR: ROI: [3:4, 85:115, 48:100, 48:100] (1, 30, 52, 52), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tRAW_CROPPED: ROI: [3:4, 85:115, 48:100, 48:100] (1, 30, 52, 52), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tMAXIMA: ROI: [3:4, 85:115, 48:100, 48:100] (1, 30, 52, 52), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tPARENT_VECTORS: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tCELL_MASK: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tTRACKS: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), dtype: , directed: None, placeholder: False\n", - "\tCENTER_TRACKS: ROI: [2:4, 85:115, 48:100, 48:100] (2, 30, 52, 52), dtype: , directed: None, placeholder: False\n", - "\n", - "2022-06-24 06:01:58,826 linajea.training.train INFO Snapshot request: \n", - "\tRAW: ROI: [0:7, 0:200, 0:148, 0:148] (7, 200, 148, 148), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tPRED_CELL_INDICATOR: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tGRAD_CELL_INDICATOR: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tRAW_CROPPED: ROI: [3:4, 85:115, 48:100, 48:100] (1, 30, 52, 52), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tMAXIMA: ROI: [3:4, 85:115, 48:100, 48:100] (1, 30, 52, 52), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tPRED_PARENT_VECTORS: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\tGRAD_PARENT_VECTORS: ROI: [3:4, 80:120, 44:104, 44:104] (1, 40, 60, 60), voxel size: None, interpolatable: None, non-spatial: False, dtype: None, placeholder: False\n", - "\n", - "2022-06-24 06:01:58,827 linajea.training.train INFO loading data /nrs/funke/hirschp/mskcc_emb1 (val: False)\n", - "2022-06-24 06:01:58,843 linajea.training.train WARNING Cannot find divisions_file in data_config, falling back to using tracks_file(usually ok unless they are not included and there is a separate file containing the divisions)\n", - "2022-06-24 06:01:58,844 linajea.training.train INFO creating source: /nrs/funke/hirschp/mskcc_emb1/emb1.zarr (volumes/raw_nested, /nrs/funke/hirschp/mskcc_emb1/mskcc_emb1_tracks.txt, /nrs/funke/hirschp/mskcc_emb1/mskcc_emb1_tracks_daughters.txt), divisions?: True\n", - "2022-06-24 06:01:58,845 linajea.training.train INFO limiting to roi: [0:50, 0:205, 0:512, 0:512] (50, 205, 512, 512)\n", - "2022-06-24 06:01:58,845 linajea.training.train INFO minmax normalization 2000 7500\n", - "2022-06-24 06:01:58,846 linajea.training.train INFO loading data /nrs/funke/hirschp/mskcc_emb (val: True)\n", - "2022-06-24 06:01:58,847 linajea.training.train WARNING Cannot find divisions_file in data_config, falling back to using tracks_file(usually ok unless they are not included and there is a separate file containing the divisions)\n", - "2022-06-24 06:01:58,848 linajea.training.train INFO creating source: /nrs/funke/hirschp/mskcc_emb/emb.zarr (volumes/raw_nested, /nrs/funke/hirschp/mskcc_emb/mskcc_emb_tracks.txt, /nrs/funke/hirschp/mskcc_emb/mskcc_emb_tracks_daughters.txt), divisions?: True\n", - "2022-06-24 06:01:58,848 linajea.training.train INFO limiting to roi: [0:50, 0:205, 0:512, 0:512] (50, 205, 512, 512)\n", - "2022-06-24 06:01:58,849 linajea.training.train INFO minmax normalization 2000 7500\n", - "2022-06-24 06:01:58,850 gunpowder.nodes.zoom_augment INFO using zoom/scale augment 0.75 1.5 {RAW: 1} {}\n", - "2022-06-24 06:01:58,850 gunpowder.nodes.noise_augment INFO using noise augment speckle {'var': [0.05]}\n", - "2022-06-24 06:01:58,850 gunpowder.nodes.noise_augment INFO using noise augment s&p {'amount': [0.0001]}\n", - "2022-06-24 06:02:01,204 gunpowder.nodes.random_location INFO requesting all CENTER_TRACKS points...\n", - "2022-06-24 06:02:02,503 gunpowder.nodes.random_location INFO requesting all CENTER_TRACKS points...\n", - "2022-06-24 06:02:02,517 gunpowder.nodes.histogram_augment INFO setting up histogram augment\n", - "2022-06-24 06:02:05,273 gunpowder.nodes.random_location INFO requesting all CENTER_TRACKS points...\n", - "2022-06-24 06:02:06,722 gunpowder.nodes.random_location INFO requesting all CENTER_TRACKS points...\n", - "2022-06-24 06:02:06,738 linajea.training.train INFO Starting training...\n", - "2022-06-24 06:02:09,052 gunpowder.torch.nodes.train INFO using auto mixed precision\n", - "2022-06-24 06:02:09,071 gunpowder.torch.nodes.train INFO Resuming training from iteration 10\n", - "2022-06-24 06:02:09,073 gunpowder.torch.nodes.train INFO Loading /groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/experiments/mskc_test_1/train_net_checkpoint_10\n", - "2022-06-24 06:02:10,774 gunpowder.torch.nodes.train WARNING no scaler state dict in checkpoint!\n", - "2022-06-24 06:02:10,776 gunpowder.torch.nodes.train INFO Using device cuda\n", - " 0%| | 0/1 [00:04\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-24 13:44:40,123 linajea.evaluation.match INFO Done matching, found 234 matches and 0 edge fps\n", - "2022-06-24 13:44:40,123 linajea.evaluation.evaluate INFO Done matching. Evaluating\n", - "2022-06-24 13:44:40,145 linajea.evaluation.evaluator INFO Getting AEFTL and ERL\n", - "2022-06-24 13:44:40,155 linajea.evaluation.evaluator INFO Getting perfect segments\n", - "2022-06-24 13:44:40,165 linajea.evaluation.evaluator INFO track range 59 50\n", - "2022-06-24 13:44:40,167 linajea.evaluation.evaluator INFO error free tracks: 28/30 1.0\n", - "2022-06-24 13:44:40,177 linajea.evaluation.evaluate_setup INFO Done evaluating results for 6. Saving results to mongo.\n", - "2022-06-24 13:44:40,178 linajea.evaluation.evaluate_setup INFO Result summary: {'gt_tracks': 24, 'rec_tracks': 26, 'gt_matched_tracks': 24, 'rec_matched_tracks': 24, 'gt_edges': 234, 'rec_edges': 252, 'matched_edges': 234, 'gt_divisions': 4, 'rec_divisions': 4, 'fp_edges': 18, 'fn_edges': 0, 'identity_switches': 0, 'fp_divisions': 0, 'iso_fp_division': 0, 'fn_divisions': 0, 'iso_fn_division': 0, 'fn_divs_no_connections': 0, 'fn_divs_unconnected_child': 0, 'fn_divs_unconnected_parent': 0, 'precision': 0.9285714285714286, 'recall': 1.0, 'f_score': 0.962962962962963, 'aeftl': 9.75, 'erl': 10.247863247863247, 'correct_segments': {'1': (234, 234), '2': (206, 206), '3': (180, 180), '4': (154, 154), '5': (128, 128), '6': (102, 102), '7': (76, 76), '8': (50, 50), '9': (24, 24), '10': (0, 0), '11': (0, 0), '12': (0, 0), '13': (0, 0), '14': (0, 0), '15': (0, 0), '16': (0, 0), '17': (0, 0), '18': (0, 0), '19': (0, 0), '20': (0, 0), '21': (0, 0), '22': (0, 0), '23': (0, 0), '24': (0, 0), '25': (0, 0), '26': (0, 0), '27': (0, 0), '28': (0, 0), '29': (0, 0), '30': (0, 0), '31': (0, 0), '32': (0, 0), '33': (0, 0), '34': (0, 0), '35': (0, 0), '36': (0, 0), '37': (0, 0), '38': (0, 0), '39': (0, 0), '40': (0, 0), '41': (0, 0), '42': (0, 0), '43': (0, 0), '44': (0, 0), '45': (0, 0), '46': (0, 0), '47': (0, 0), '48': (0, 0), '49': (0, 0), '50': (0, 0), '51': (0, 0), '52': (0, 0), '53': (0, 0), '54': (0, 0), '55': (0, 0), '56': (0, 0), '57': (0, 0), '58': (0, 0), '59': (0, 0), '60': (0, 0), '61': (0, 0), '62': (0, 0), '63': (0, 0), '64': (0, 0), '65': (0, 0), '66': (0, 0), '67': (0, 0), '68': (0, 0), '69': (0, 0), '70': (0, 0), '71': (0, 0), '72': (0, 0), '73': (0, 0), '74': (0, 0), '75': (0, 0), '76': (0, 0), '77': (0, 0), '78': (0, 0), '79': (0, 0), '80': (0, 0), '81': (0, 0), '82': (0, 0), '83': (0, 0), '84': (0, 0), '85': (0, 0), '86': (0, 0), '87': (0, 0), '88': (0, 0), '89': (0, 0), '90': (0, 0), '91': (0, 0), '92': (0, 0), '93': (0, 0), '94': (0, 0), '95': (0, 0), '96': (0, 0), '97': (0, 0), '98': (0, 0), '99': (0, 0), '100': (0, 0), '101': (0, 0), '102': (0, 0), '103': (0, 0), '104': (0, 0), '105': (0, 0), '106': (0, 0), '107': (0, 0), '108': (0, 0), '109': (0, 0), '110': (0, 0), '111': (0, 0), '112': (0, 0), '113': (0, 0), '114': (0, 0), '115': (0, 0), '116': (0, 0), '117': (0, 0), '118': (0, 0), '119': (0, 0), '120': (0, 0), '121': (0, 0), '122': (0, 0), '123': (0, 0), '124': (0, 0), '125': (0, 0), '126': (0, 0), '127': (0, 0), '128': (0, 0), '129': (0, 0), '130': (0, 0), '131': (0, 0), '132': (0, 0), '133': (0, 0), '134': (0, 0), '135': (0, 0), '136': (0, 0), '137': (0, 0), '138': (0, 0), '139': (0, 0), '140': (0, 0), '141': (0, 0), '142': (0, 0), '143': (0, 0), '144': (0, 0), '145': (0, 0), '146': (0, 0), '147': (0, 0), '148': (0, 0), '149': (0, 0), '150': (0, 0), '151': (0, 0), '152': (0, 0), '153': (0, 0), '154': (0, 0), '155': (0, 0), '156': (0, 0), '157': (0, 0), '158': (0, 0), '159': (0, 0), '160': (0, 0), '161': (0, 0), '162': (0, 0), '163': (0, 0), '164': (0, 0), '165': (0, 0), '166': (0, 0), '167': (0, 0), '168': (0, 0), '169': (0, 0), '170': (0, 0), '171': (0, 0), '172': (0, 0), '173': (0, 0), '174': (0, 0), '175': (0, 0), '176': (0, 0), '177': (0, 0), '178': (0, 0), '179': (0, 0), '180': (0, 0), '181': (0, 0), '182': (0, 0), '183': (0, 0), '184': (0, 0), '185': (0, 0), '186': (0, 0), '187': (0, 0), '188': (0, 0), '189': (0, 0), '190': (0, 0), '191': (0, 0), '192': (0, 0), '193': (0, 0), '194': (0, 0), '195': (0, 0), '196': (0, 0), '197': (0, 0), '198': (0, 0), '199': (0, 0), '200': (0, 0), '201': (0, 0), '202': (0, 0), '203': (0, 0), '204': (0, 0), '205': (0, 0), '206': (0, 0), '207': (0, 0), '208': (0, 0), '209': (0, 0), '210': (0, 0), '211': (0, 0), '212': (0, 0), '213': (0, 0), '214': (0, 0), '215': (0, 0), '216': (0, 0), '217': (0, 0), '218': (0, 0), '219': (0, 0), '220': (0, 0), '221': (0, 0), '222': (0, 0), '223': (0, 0), '224': (0, 0), '225': (0, 0), '226': (0, 0), '227': (0, 0), '228': (0, 0), '229': (0, 0), '230': (0, 0), '231': (0, 0), '232': (0, 0), '233': (0, 0), '234': (0, 0), '235': (0, 0), '236': (0, 0), '237': (0, 0), '238': (0, 0), '239': (0, 0), '240': (0, 0), '241': (0, 0), '242': (0, 0), '243': (0, 0), '244': (0, 0), '245': (0, 0), '246': (0, 0), '247': (0, 0), '248': (0, 0), '249': (0, 0), '250': (0, 0), '251': (0, 0), '252': (0, 0), '253': (0, 0), '254': (0, 0), '255': (0, 0), '256': (0, 0), '257': (0, 0), '258': (0, 0), '259': (0, 0), '260': (0, 0), '261': (0, 0), '262': (0, 0), '263': (0, 0), '264': (0, 0), '265': (0, 0), '266': (0, 0), '267': (0, 0), '268': (0, 0), '269': (0, 0), '270': (0, 0)}, 'validation_score': None, 'fn_div_count_unconnected_parent': False, 'num_error_free_tracks': 28, 'num_rec_cells_last_frame': 30, 'num_gt_cells_last_frame': 28, 'node_recall': 1.0, 'edge_recall': 0.9829059829059829}\n", - "2022-06-24 13:44:40,211 linajea.utils.candidate_database INFO writing scores for {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 9, 'weight_division': -8, 'division_constant': 2.5, 'weight_child': 2.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'feature_func': 'noop', 'val': True} to {'param_id': 6, 'matching_threshold': 15, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'validation_score': False, 'window_size': 50, 'filter_short_tracklets_len': -1, 'ignore_one_off_div_errors': False, 'fn_div_count_unconnected_parent': True, 'sparse': False}\n", - "2022-06-24 13:44:40,215 linajea.evaluation.evaluate_setup INFO roi DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512)) DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512))\n", - "2022-06-24 13:44:40,235 linajea.utils.candidate_database INFO Querying ID for parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 12, 'weight_division': -11, 'division_constant': 2.5, 'weight_child': 2.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}}\n", - "2022-06-24 13:44:40,237 linajea.utils.candidate_database INFO Parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 12, 'weight_division': -11, 'division_constant': 2.5, 'weight_child': 2.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}} already in collection with id 52\n", - "2022-06-24 13:44:40,253 linajea.evaluation.evaluate_setup INFO Evaluating mskcc_emb in [50:60, 0:205, 0:512, 0:512] (10, 205, 512, 512)\n", - "2022-06-24 13:44:40,275 linajea.evaluation.evaluate_setup INFO Reading cells and edges in db linajea_celegans_20220624_134111 with parameter_id 52\n", - "2022-06-24 13:44:40,282 linajea.evaluation.evaluate_setup INFO Read 0 cells and 0 edges in 0.006875514984130859 seconds\n", - "2022-06-24 13:44:40,282 linajea.evaluation.evaluate_setup WARNING No selected edges for parameters_id 52. Skipping\n", - "2022-06-24 13:44:40,284 linajea.evaluation.evaluate_setup INFO roi DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512)) DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512))\n", - "2022-06-24 13:44:40,305 linajea.utils.candidate_database INFO Querying ID for parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 6, 'weight_division': -8, 'division_constant': 2.5, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}}\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-24 13:44:40,306 linajea.utils.candidate_database INFO Parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 6, 'weight_division': -8, 'division_constant': 2.5, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}} already in collection with id 17\n", - "2022-06-24 13:44:40,324 linajea.evaluation.evaluate_setup INFO Evaluating mskcc_emb in [50:60, 0:205, 0:512, 0:512] (10, 205, 512, 512)\n", - "2022-06-24 13:44:40,344 linajea.evaluation.evaluate_setup INFO Reading cells and edges in db linajea_celegans_20220624_134111 with parameter_id 17\n", - "2022-06-24 13:44:40,355 linajea.evaluation.evaluate_setup INFO Read 305 cells and 279 edges in 0.009444236755371094 seconds\n", - "2022-06-24 13:44:40,364 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,366 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,366 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,367 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,368 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 18\n", - "2022-06-24 13:44:40,368 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,369 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 50, track len: 1\n", - "2022-06-24 13:44:40,369 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 18\n", - "2022-06-24 13:44:40,369 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,369 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,370 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,370 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,370 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,370 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,371 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,371 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,371 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 11\n", - "2022-06-24 13:44:40,372 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,372 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,372 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,372 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,373 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,373 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 11\n", - "2022-06-24 13:44:40,373 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,373 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,374 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,374 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,399 linajea.evaluation.evaluate_setup INFO Reading ground truth cells and edges in db linajea_celegans_emb_fixed\n", - "2022-06-24 13:44:40,408 linajea.evaluation.evaluate_setup INFO Read 282 cells and 258 edges in 0.00831151008605957 seconds\n", - "2022-06-24 13:44:40,411 linajea.evaluation.evaluate_setup INFO Matching edges for parameters with id 17\n", - "2022-06-24 13:44:40,411 linajea.evaluation.evaluate INFO Checking validity of reconstruction\n", - "2022-06-24 13:44:40,412 linajea.evaluation.evaluate INFO Matching GT edges to REC edges...\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "False\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-24 13:44:40,562 linajea.evaluation.match INFO Done matching, found 233 matches and 0 edge fps\n", - "2022-06-24 13:44:40,562 linajea.evaluation.evaluate INFO Done matching. Evaluating\n", - "2022-06-24 13:44:40,583 linajea.evaluation.evaluator INFO Getting AEFTL and ERL\n", - "2022-06-24 13:44:40,593 linajea.evaluation.evaluator INFO Getting perfect segments\n", - "2022-06-24 13:44:40,604 linajea.evaluation.evaluator INFO track range 59 50\n", - "2022-06-24 13:44:40,607 linajea.evaluation.evaluator INFO error free tracks: 27/30 0.9642857142857143\n", - "2022-06-24 13:44:40,616 linajea.evaluation.evaluate_setup INFO Done evaluating results for 17. Saving results to mongo.\n", - "2022-06-24 13:44:40,617 linajea.evaluation.evaluate_setup INFO Result summary: {'gt_tracks': 24, 'rec_tracks': 27, 'gt_matched_tracks': 24, 'rec_matched_tracks': 24, 'gt_edges': 234, 'rec_edges': 252, 'matched_edges': 233, 'gt_divisions': 4, 'rec_divisions': 4, 'fp_edges': 19, 'fn_edges': 0, 'identity_switches': 0, 'fp_divisions': 0, 'iso_fp_division': 0, 'fn_divisions': 0, 'iso_fn_division': 1, 'fn_divs_no_connections': 0, 'fn_divs_unconnected_child': 0, 'fn_divs_unconnected_parent': 1, 'precision': 0.9246031746031746, 'recall': 0.9957264957264957, 'f_score': 0.9588477366255145, 'aeftl': 9.708333333333334, 'erl': 10.106837606837606, 'correct_segments': {'1': (233, 234), '2': (205, 206), '3': (179, 180), '4': (153, 154), '5': (127, 128), '6': (101, 102), '7': (75, 76), '8': (49, 50), '9': (23, 24), '10': (0, 0), '11': (0, 0), '12': (0, 0), '13': (0, 0), '14': (0, 0), '15': (0, 0), '16': (0, 0), '17': (0, 0), '18': (0, 0), '19': (0, 0), '20': (0, 0), '21': (0, 0), '22': (0, 0), '23': (0, 0), '24': (0, 0), '25': (0, 0), '26': (0, 0), '27': (0, 0), '28': (0, 0), '29': (0, 0), '30': (0, 0), '31': (0, 0), '32': (0, 0), '33': (0, 0), '34': (0, 0), '35': (0, 0), '36': (0, 0), '37': (0, 0), '38': (0, 0), '39': (0, 0), '40': (0, 0), '41': (0, 0), '42': (0, 0), '43': (0, 0), '44': (0, 0), '45': (0, 0), '46': (0, 0), '47': (0, 0), '48': (0, 0), '49': (0, 0), '50': (0, 0), '51': (0, 0), '52': (0, 0), '53': (0, 0), '54': (0, 0), '55': (0, 0), '56': (0, 0), '57': (0, 0), '58': (0, 0), '59': (0, 0), '60': (0, 0), '61': (0, 0), '62': (0, 0), '63': (0, 0), '64': (0, 0), '65': (0, 0), '66': (0, 0), '67': (0, 0), '68': (0, 0), '69': (0, 0), '70': (0, 0), '71': (0, 0), '72': (0, 0), '73': (0, 0), '74': (0, 0), '75': (0, 0), '76': (0, 0), '77': (0, 0), '78': (0, 0), '79': (0, 0), '80': (0, 0), '81': (0, 0), '82': (0, 0), '83': (0, 0), '84': (0, 0), '85': (0, 0), '86': (0, 0), '87': (0, 0), '88': (0, 0), '89': (0, 0), '90': (0, 0), '91': (0, 0), '92': (0, 0), '93': (0, 0), '94': (0, 0), '95': (0, 0), '96': (0, 0), '97': (0, 0), '98': (0, 0), '99': (0, 0), '100': (0, 0), '101': (0, 0), '102': (0, 0), '103': (0, 0), '104': (0, 0), '105': (0, 0), '106': (0, 0), '107': (0, 0), '108': (0, 0), '109': (0, 0), '110': (0, 0), '111': (0, 0), '112': (0, 0), '113': (0, 0), '114': (0, 0), '115': (0, 0), '116': (0, 0), '117': (0, 0), '118': (0, 0), '119': (0, 0), '120': (0, 0), '121': (0, 0), '122': (0, 0), '123': (0, 0), '124': (0, 0), '125': (0, 0), '126': (0, 0), '127': (0, 0), '128': (0, 0), '129': (0, 0), '130': (0, 0), '131': (0, 0), '132': (0, 0), '133': (0, 0), '134': (0, 0), '135': (0, 0), '136': (0, 0), '137': (0, 0), '138': (0, 0), '139': (0, 0), '140': (0, 0), '141': (0, 0), '142': (0, 0), '143': (0, 0), '144': (0, 0), '145': (0, 0), '146': (0, 0), '147': (0, 0), '148': (0, 0), '149': (0, 0), '150': (0, 0), '151': (0, 0), '152': (0, 0), '153': (0, 0), '154': (0, 0), '155': (0, 0), '156': (0, 0), '157': (0, 0), '158': (0, 0), '159': (0, 0), '160': (0, 0), '161': (0, 0), '162': (0, 0), '163': (0, 0), '164': (0, 0), '165': (0, 0), '166': (0, 0), '167': (0, 0), '168': (0, 0), '169': (0, 0), '170': (0, 0), '171': (0, 0), '172': (0, 0), '173': (0, 0), '174': (0, 0), '175': (0, 0), '176': (0, 0), '177': (0, 0), '178': (0, 0), '179': (0, 0), '180': (0, 0), '181': (0, 0), '182': (0, 0), '183': (0, 0), '184': (0, 0), '185': (0, 0), '186': (0, 0), '187': (0, 0), '188': (0, 0), '189': (0, 0), '190': (0, 0), '191': (0, 0), '192': (0, 0), '193': (0, 0), '194': (0, 0), '195': (0, 0), '196': (0, 0), '197': (0, 0), '198': (0, 0), '199': (0, 0), '200': (0, 0), '201': (0, 0), '202': (0, 0), '203': (0, 0), '204': (0, 0), '205': (0, 0), '206': (0, 0), '207': (0, 0), '208': (0, 0), '209': (0, 0), '210': (0, 0), '211': (0, 0), '212': (0, 0), '213': (0, 0), '214': (0, 0), '215': (0, 0), '216': (0, 0), '217': (0, 0), '218': (0, 0), '219': (0, 0), '220': (0, 0), '221': (0, 0), '222': (0, 0), '223': (0, 0), '224': (0, 0), '225': (0, 0), '226': (0, 0), '227': (0, 0), '228': (0, 0), '229': (0, 0), '230': (0, 0), '231': (0, 0), '232': (0, 0), '233': (0, 0), '234': (0, 0), '235': (0, 0), '236': (0, 0), '237': (0, 0), '238': (0, 0), '239': (0, 0), '240': (0, 0), '241': (0, 0), '242': (0, 0), '243': (0, 0), '244': (0, 0), '245': (0, 0), '246': (0, 0), '247': (0, 0), '248': (0, 0), '249': (0, 0), '250': (0, 0), '251': (0, 0), '252': (0, 0), '253': (0, 0), '254': (0, 0), '255': (0, 0), '256': (0, 0), '257': (0, 0), '258': (0, 0), '259': (0, 0), '260': (0, 0), '261': (0, 0), '262': (0, 0), '263': (0, 0), '264': (0, 0), '265': (0, 0), '266': (0, 0), '267': (0, 0), '268': (0, 0), '269': (0, 0), '270': (0, 0)}, 'validation_score': None, 'fn_div_count_unconnected_parent': False, 'num_error_free_tracks': 27, 'num_rec_cells_last_frame': 30, 'num_gt_cells_last_frame': 28, 'node_recall': 1.0, 'edge_recall': 0.9829059829059829}\n", - "2022-06-24 13:44:40,648 linajea.utils.candidate_database INFO writing scores for {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 6, 'weight_division': -8, 'division_constant': 2.5, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'feature_func': 'noop', 'val': True} to {'param_id': 17, 'matching_threshold': 15, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'validation_score': False, 'window_size': 50, 'filter_short_tracklets_len': -1, 'ignore_one_off_div_errors': False, 'fn_div_count_unconnected_parent': True, 'sparse': False}\n", - "2022-06-24 13:44:40,652 linajea.evaluation.evaluate_setup INFO roi DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512)) DataROIConfig(offset=(50, 0, 0, 0), shape=(10, 205, 512, 512))\n", - "2022-06-24 13:44:40,674 linajea.utils.candidate_database INFO Querying ID for parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 9, 'weight_division': -11, 'division_constant': 6.0, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}}\n", - "2022-06-24 13:44:40,676 linajea.utils.candidate_database INFO Parameters {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 9, 'weight_division': -11, 'division_constant': 6.0, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': True, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}} already in collection with id 43\n", - "2022-06-24 13:44:40,693 linajea.evaluation.evaluate_setup INFO Evaluating mskcc_emb in [50:60, 0:205, 0:512, 0:512] (10, 205, 512, 512)\n", - "2022-06-24 13:44:40,713 linajea.evaluation.evaluate_setup INFO Reading cells and edges in db linajea_celegans_20220624_134111 with parameter_id 43\n", - "2022-06-24 13:44:40,724 linajea.evaluation.evaluate_setup INFO Read 304 cells and 278 edges in 0.010981321334838867 seconds\n", - "2022-06-24 13:44:40,734 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,735 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,736 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-24 13:44:40,737 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,737 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 18\n", - "2022-06-24 13:44:40,738 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,738 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 18\n", - "2022-06-24 13:44:40,738 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,739 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,739 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,739 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,739 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,740 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,740 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,740 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,740 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 11\n", - "2022-06-24 13:44:40,741 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,741 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,741 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,741 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,742 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,742 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 11\n", - "2022-06-24 13:44:40,742 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,743 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,743 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,743 linajea.evaluation.evaluate_setup INFO track begin: 50, track end: 59, track len: 10\n", - "2022-06-24 13:44:40,768 linajea.evaluation.evaluate_setup INFO Reading ground truth cells and edges in db linajea_celegans_emb_fixed\n", - "2022-06-24 13:44:40,777 linajea.evaluation.evaluate_setup INFO Read 282 cells and 258 edges in 0.008458852767944336 seconds\n", - "2022-06-24 13:44:40,780 linajea.evaluation.evaluate_setup INFO Matching edges for parameters with id 43\n", - "2022-06-24 13:44:40,781 linajea.evaluation.evaluate INFO Checking validity of reconstruction\n", - "2022-06-24 13:44:40,781 linajea.evaluation.evaluate INFO Matching GT edges to REC edges...\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-24 13:44:40,931 linajea.evaluation.match INFO Done matching, found 234 matches and 0 edge fps\n", - "2022-06-24 13:44:40,931 linajea.evaluation.evaluate INFO Done matching. Evaluating\n", - "2022-06-24 13:44:40,953 linajea.evaluation.evaluator INFO Getting AEFTL and ERL\n", - "2022-06-24 13:44:40,963 linajea.evaluation.evaluator INFO Getting perfect segments\n", - "2022-06-24 13:44:40,973 linajea.evaluation.evaluator INFO track range 59 50\n", - "2022-06-24 13:44:40,976 linajea.evaluation.evaluator INFO error free tracks: 28/30 1.0\n", - "2022-06-24 13:44:40,985 linajea.evaluation.evaluate_setup INFO Done evaluating results for 43. Saving results to mongo.\n", - "2022-06-24 13:44:40,986 linajea.evaluation.evaluate_setup INFO Result summary: {'gt_tracks': 24, 'rec_tracks': 26, 'gt_matched_tracks': 24, 'rec_matched_tracks': 24, 'gt_edges': 234, 'rec_edges': 252, 'matched_edges': 234, 'gt_divisions': 4, 'rec_divisions': 4, 'fp_edges': 18, 'fn_edges': 0, 'identity_switches': 0, 'fp_divisions': 0, 'iso_fp_division': 0, 'fn_divisions': 0, 'iso_fn_division': 0, 'fn_divs_no_connections': 0, 'fn_divs_unconnected_child': 0, 'fn_divs_unconnected_parent': 0, 'precision': 0.9285714285714286, 'recall': 1.0, 'f_score': 0.962962962962963, 'aeftl': 9.75, 'erl': 10.247863247863247, 'correct_segments': {'1': (234, 234), '2': (206, 206), '3': (180, 180), '4': (154, 154), '5': (128, 128), '6': (102, 102), '7': (76, 76), '8': (50, 50), '9': (24, 24), '10': (0, 0), '11': (0, 0), '12': (0, 0), '13': (0, 0), '14': (0, 0), '15': (0, 0), '16': (0, 0), '17': (0, 0), '18': (0, 0), '19': (0, 0), '20': (0, 0), '21': (0, 0), '22': (0, 0), '23': (0, 0), '24': (0, 0), '25': (0, 0), '26': (0, 0), '27': (0, 0), '28': (0, 0), '29': (0, 0), '30': (0, 0), '31': (0, 0), '32': (0, 0), '33': (0, 0), '34': (0, 0), '35': (0, 0), '36': (0, 0), '37': (0, 0), '38': (0, 0), '39': (0, 0), '40': (0, 0), '41': (0, 0), '42': (0, 0), '43': (0, 0), '44': (0, 0), '45': (0, 0), '46': (0, 0), '47': (0, 0), '48': (0, 0), '49': (0, 0), '50': (0, 0), '51': (0, 0), '52': (0, 0), '53': (0, 0), '54': (0, 0), '55': (0, 0), '56': (0, 0), '57': (0, 0), '58': (0, 0), '59': (0, 0), '60': (0, 0), '61': (0, 0), '62': (0, 0), '63': (0, 0), '64': (0, 0), '65': (0, 0), '66': (0, 0), '67': (0, 0), '68': (0, 0), '69': (0, 0), '70': (0, 0), '71': (0, 0), '72': (0, 0), '73': (0, 0), '74': (0, 0), '75': (0, 0), '76': (0, 0), '77': (0, 0), '78': (0, 0), '79': (0, 0), '80': (0, 0), '81': (0, 0), '82': (0, 0), '83': (0, 0), '84': (0, 0), '85': (0, 0), '86': (0, 0), '87': (0, 0), '88': (0, 0), '89': (0, 0), '90': (0, 0), '91': (0, 0), '92': (0, 0), '93': (0, 0), '94': (0, 0), '95': (0, 0), '96': (0, 0), '97': (0, 0), '98': (0, 0), '99': (0, 0), '100': (0, 0), '101': (0, 0), '102': (0, 0), '103': (0, 0), '104': (0, 0), '105': (0, 0), '106': (0, 0), '107': (0, 0), '108': (0, 0), '109': (0, 0), '110': (0, 0), '111': (0, 0), '112': (0, 0), '113': (0, 0), '114': (0, 0), '115': (0, 0), '116': (0, 0), '117': (0, 0), '118': (0, 0), '119': (0, 0), '120': (0, 0), '121': (0, 0), '122': (0, 0), '123': (0, 0), '124': (0, 0), '125': (0, 0), '126': (0, 0), '127': (0, 0), '128': (0, 0), '129': (0, 0), '130': (0, 0), '131': (0, 0), '132': (0, 0), '133': (0, 0), '134': (0, 0), '135': (0, 0), '136': (0, 0), '137': (0, 0), '138': (0, 0), '139': (0, 0), '140': (0, 0), '141': (0, 0), '142': (0, 0), '143': (0, 0), '144': (0, 0), '145': (0, 0), '146': (0, 0), '147': (0, 0), '148': (0, 0), '149': (0, 0), '150': (0, 0), '151': (0, 0), '152': (0, 0), '153': (0, 0), '154': (0, 0), '155': (0, 0), '156': (0, 0), '157': (0, 0), '158': (0, 0), '159': (0, 0), '160': (0, 0), '161': (0, 0), '162': (0, 0), '163': (0, 0), '164': (0, 0), '165': (0, 0), '166': (0, 0), '167': (0, 0), '168': (0, 0), '169': (0, 0), '170': (0, 0), '171': (0, 0), '172': (0, 0), '173': (0, 0), '174': (0, 0), '175': (0, 0), '176': (0, 0), '177': (0, 0), '178': (0, 0), '179': (0, 0), '180': (0, 0), '181': (0, 0), '182': (0, 0), '183': (0, 0), '184': (0, 0), '185': (0, 0), '186': (0, 0), '187': (0, 0), '188': (0, 0), '189': (0, 0), '190': (0, 0), '191': (0, 0), '192': (0, 0), '193': (0, 0), '194': (0, 0), '195': (0, 0), '196': (0, 0), '197': (0, 0), '198': (0, 0), '199': (0, 0), '200': (0, 0), '201': (0, 0), '202': (0, 0), '203': (0, 0), '204': (0, 0), '205': (0, 0), '206': (0, 0), '207': (0, 0), '208': (0, 0), '209': (0, 0), '210': (0, 0), '211': (0, 0), '212': (0, 0), '213': (0, 0), '214': (0, 0), '215': (0, 0), '216': (0, 0), '217': (0, 0), '218': (0, 0), '219': (0, 0), '220': (0, 0), '221': (0, 0), '222': (0, 0), '223': (0, 0), '224': (0, 0), '225': (0, 0), '226': (0, 0), '227': (0, 0), '228': (0, 0), '229': (0, 0), '230': (0, 0), '231': (0, 0), '232': (0, 0), '233': (0, 0), '234': (0, 0), '235': (0, 0), '236': (0, 0), '237': (0, 0), '238': (0, 0), '239': (0, 0), '240': (0, 0), '241': (0, 0), '242': (0, 0), '243': (0, 0), '244': (0, 0), '245': (0, 0), '246': (0, 0), '247': (0, 0), '248': (0, 0), '249': (0, 0), '250': (0, 0), '251': (0, 0), '252': (0, 0), '253': (0, 0), '254': (0, 0), '255': (0, 0), '256': (0, 0), '257': (0, 0), '258': (0, 0), '259': (0, 0), '260': (0, 0), '261': (0, 0), '262': (0, 0), '263': (0, 0), '264': (0, 0), '265': (0, 0), '266': (0, 0), '267': (0, 0), '268': (0, 0), '269': (0, 0), '270': (0, 0)}, 'validation_score': None, 'fn_div_count_unconnected_parent': False, 'num_error_free_tracks': 28, 'num_rec_cells_last_frame': 30, 'num_gt_cells_last_frame': 28, 'node_recall': 1.0, 'edge_recall': 0.9829059829059829}\n", - "2022-06-24 13:44:41,017 linajea.utils.candidate_database INFO writing scores for {'track_cost': 7, 'weight_node_score': -13, 'selection_constant': 9, 'weight_division': -11, 'division_constant': 6.0, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'feature_func': 'noop', 'val': True} to {'param_id': 43, 'matching_threshold': 15, 'roi': {'offset': [50, 0, 0, 0], 'shape': [10, 205, 512, 512]}, 'validation_score': False, 'window_size': 50, 'filter_short_tracklets_len': -1, 'ignore_one_off_div_errors': False, 'fn_div_count_unconnected_parent': True, 'sparse': False}\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], + "outputs": [], "source": [ "import importlib\n", "importlib.reload(linajea.evaluation)\n", @@ -1429,7 +266,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "e396ec9a", "metadata": {}, "outputs": [], @@ -1442,249 +279,10 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "73142808", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-24 10:50:02,323 linajea.utils.check_or_create_db INFO linajea_celegans_20220623_180937: {'setup_dir': 'mskc_test_1', 'iteration': 10, 'cell_score_threshold': 0.2, 'sample': 'mskcc_emb3'} (accessed)\n", - "2022-06-24 10:50:02,331 linajea.process_blockwise.predict_blockwise INFO Following ROIs in world units:\n", - "2022-06-24 10:50:02,331 linajea.process_blockwise.predict_blockwise INFO Input ROI = [45:65, -85:315, -50:690, -50:690] (20, 400, 740, 740)\n", - "2022-06-24 10:50:02,332 linajea.process_blockwise.predict_blockwise INFO Block read ROI = [-3:4, -85:315, -50:210, -50:210] (7, 400, 260, 260)\n", - "2022-06-24 10:50:02,332 linajea.process_blockwise.predict_blockwise INFO Block write ROI = [0:1, 0:230, 0:160, 0:160] (1, 230, 160, 160)\n", - "2022-06-24 10:50:02,332 linajea.process_blockwise.predict_blockwise INFO Output ROI = [48:62, 0:230, 0:640, 0:640] (14, 230, 640, 640)\n", - "2022-06-24 10:50:02,332 linajea.process_blockwise.predict_blockwise INFO Starting block-wise processing...\n", - "2022-06-24 10:50:02,333 linajea.process_blockwise.predict_blockwise INFO Sample: /nrs/funke/hirschp/mskcc_emb3\n", - "2022-06-24 10:50:02,333 linajea.process_blockwise.predict_blockwise INFO DB: linajea_celegans_20220623_180937\n", - "2022-06-24 10:50:02,388 daisy.scheduler INFO Scheduling 224 tasks to completion.\n", - "2022-06-24 10:50:02,388 daisy.scheduler INFO Max parallelism seems to be 224.\n", - "2022-06-24 10:50:02,389 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", - "\t\t0 finished (0 skipped, 0 succeeded, 0 failed), 0 processing, 224 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:50:04,072 daisy.scheduler INFO Launching workers for task BlockwiseTask\n", - "2022-06-24 10:50:04,099 linajea.process_blockwise.predict_blockwise INFO Starting predict worker...\n", - "2022-06-24 10:50:04,103 linajea.process_blockwise.predict_blockwise INFO Command: ['python -u /groups/funke/home/hirschp/tracking/linajea/linajea/prediction/predict.py --config tmp_configs/config_1656082202.324147.toml']\n", - "2022-06-24 10:50:12,400 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:50:12,402 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", - "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:50:22,412 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:50:22,413 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", - "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:50:32,423 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:50:32,424 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", - "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:50:42,435 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:50:42,436 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", - "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:50:52,446 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:50:52,447 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", - "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:51:02,457 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:51:02,458 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", - "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:51:12,469 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:51:12,470 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", - "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:51:22,480 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:51:22,481 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (0 aliases online)\n", - "\t\t111 finished (111 skipped, 0 succeeded, 0 failed), 1 processing, 112 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:51:32,492 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:51:32,492 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:51:42,503 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:51:42,504 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:51:52,514 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:51:52,515 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:52:02,525 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:52:02,526 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:52:12,537 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:52:12,538 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:52:22,548 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:52:22,549 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:52:32,559 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:52:32,560 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:52:42,570 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:52:42,571 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:52:52,582 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:52:52,583 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:53:02,593 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:53:02,594 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t122 finished (122 skipped, 0 succeeded, 0 failed), 2 processing, 100 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:53:12,604 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:53:12,605 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t126 finished (124 skipped, 2 succeeded, 0 failed), 2 processing, 96 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:53:22,615 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:53:22,616 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t141 finished (134 skipped, 7 succeeded, 0 failed), 1 processing, 82 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:53:32,627 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:53:32,627 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t150 finished (139 skipped, 11 succeeded, 0 failed), 2 processing, 72 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:53:42,638 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:53:42,639 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t157 finished (140 skipped, 17 succeeded, 0 failed), 1 processing, 66 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:53:52,649 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:53:52,650 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t164 finished (143 skipped, 21 succeeded, 0 failed), 2 processing, 58 pending\n", - "\t\tETA: unknown\n", - "2022-06-24 10:54:02,660 daisy.scheduler INFO \n", - "\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-24 10:54:02,661 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t169 finished (143 skipped, 26 succeeded, 0 failed), 2 processing, 53 pending\n", - "\t\tETA: 0:04:04.615385\n", - "2022-06-24 10:54:12,671 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:54:12,672 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t174 finished (143 skipped, 31 succeeded, 0 failed), 2 processing, 48 pending\n", - "\t\tETA: 0:03:41.538462\n", - "2022-06-24 10:54:22,683 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:54:22,683 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t180 finished (144 skipped, 36 succeeded, 0 failed), 2 processing, 42 pending\n", - "\t\tETA: 0:03:13.846154\n", - "2022-06-24 10:54:32,694 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:54:32,695 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t185 finished (144 skipped, 41 succeeded, 0 failed), 2 processing, 37 pending\n", - "\t\tETA: 0:02:50.769231\n", - "2022-06-24 10:54:42,705 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:54:42,706 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t190 finished (144 skipped, 46 succeeded, 0 failed), 1 processing, 33 pending\n", - "\t\tETA: 0:02:32.307692\n", - "2022-06-24 10:54:52,716 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:54:52,717 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t195 finished (144 skipped, 51 succeeded, 0 failed), 1 processing, 28 pending\n", - "\t\tETA: 0:02:09.230769\n", - "2022-06-24 10:55:02,727 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:55:02,728 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t200 finished (144 skipped, 56 succeeded, 0 failed), 1 processing, 23 pending\n", - "\t\tETA: 0:01:46.153846\n", - "2022-06-24 10:55:12,739 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:55:12,739 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t205 finished (144 skipped, 61 succeeded, 0 failed), 1 processing, 18 pending\n", - "\t\tETA: 0:01:23.076923\n", - "2022-06-24 10:55:22,750 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:55:22,751 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t209 finished (144 skipped, 65 succeeded, 0 failed), 2 processing, 13 pending\n", - "\t\tETA: 0:01:00\n", - "2022-06-24 10:55:32,761 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:55:32,762 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t214 finished (144 skipped, 70 succeeded, 0 failed), 2 processing, 8 pending\n", - "\t\tETA: 0:00:36.923077\n", - "2022-06-24 10:55:42,772 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:55:42,773 daisy.scheduler INFO \n", - "\tBlockwiseTask processing 224 blocks with 1 workers (1 aliases online)\n", - "\t\t219 finished (144 skipped, 75 succeeded, 0 failed), 2 processing, 3 pending\n", - "\t\tETA: 0:00:13.846154\n", - "2022-06-24 10:55:52,784 daisy.scheduler INFO \n", - "\n", - "2022-06-24 10:55:57,224 linajea.process_blockwise.predict_blockwise INFO Predict worker finished\n", - "2022-06-24 10:55:57,242 daisy.scheduler INFO Ran 224 tasks of which 80 succeeded, 144 were skipped, 0 were orphaned (failed dependencies), 0 tasks failed (0 failed check, 0 application errors, 0 network failures or app crashes)\n" - ] - } - ], + "outputs": [], "source": [ "for inf_config in getNextInferenceData(args):\n", " predict_blockwise(inf_config)" @@ -1702,38 +300,10 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "e1c7fe27", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-27 11:04:01,266 linajea.utils.check_or_create_db INFO linajea_celegans_20220624_134111: {'setup_dir': 'mskc_test_1', 'iteration': 10, 'cell_score_threshold': 0.2, 'sample': 'mskcc_emb'} (accessed)\n", - "2022-06-27 11:04:01,267 linajea.evaluation.analyze_results INFO checking db: linajea_celegans_20220624_134111\n", - "2022-06-27 11:04:01,295 linajea.utils.candidate_database INFO Query: {'val': True, 'matching_threshold': 15, 'validation_score': False, 'window_size': 50, 'filter_short_tracklets_len': -1, 'ignore_one_off_div_errors': False, 'fn_div_count_unconnected_parent': True, 'sparse': False}\n", - "2022-06-27 11:04:01,297 linajea.utils.candidate_database INFO Found 7 scores\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[SolveParametersMinimalConfig(track_cost=20.675083335777686, weight_node_score=-16.786125363574673, selection_constant=8.893522542354866, weight_division=-8.260920358667896, division_constant=5.9535746121046165, weight_child=1.003401421433546, weight_continuation=-1.0264581546174496, weight_edge_score=0.4254227576366175, cell_cycle_key=None, block_size=[15, 512, 512, 712], context=[2, 100, 100, 100], max_cell_move=45, roi=None, feature_func='noop', val=False, tag=None)] 1\n", - "getting results for: /nrs/funke/hirschp/mskcc_emb\n", - "SolveParametersMinimalConfig(track_cost=7.0, weight_node_score=-21.0, selection_constant=6.0, weight_division=-11.0, division_constant=5.9535746121046165, weight_child=1.0, weight_continuation=-1.0, weight_edge_score=0.35, cell_cycle_key=None, block_size=[15, 512, 512, 712], context=[2, 100, 100, 100], max_cell_move=None, roi=None, feature_func='noop', val=False, tag=None) \n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/tmp/ipykernel_1479236/396560302.py:40: FutureWarning: Dropping invalid columns in DataFrameGroupBy.agg is deprecated. In a future version, a TypeError will be raised. Before calling .agg, select only columns which should be valid for the aggregating function.\n", - " results = results.groupby(by, dropna=False, as_index=False).agg(\n" - ] - } - ], + "outputs": [], "source": [ "score_columns = ['fn_edges', 'identity_switches',\n", " 'fp_divisions', 'fn_divisions']\n", @@ -1796,77 +366,10 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "id": "7a141c33", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2022-06-24 13:51:58,318 linajea.utils.check_or_create_db INFO linajea_celegans_20220623_180937: {'setup_dir': 'mskc_test_1', 'iteration': 10, 'cell_score_threshold': 0.2, 'sample': 'mskcc_emb3'} (accessed)\n", - "2022-06-24 13:51:58,344 linajea.utils.candidate_database INFO Querying ID for parameters {'track_cost': 7.0, 'weight_node_score': -21.0, 'selection_constant': 6.0, 'weight_division': -11.0, 'division_constant': 5.9535746121046165, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': False, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}}\n", - "2022-06-24 13:51:58,348 linajea.utils.candidate_database INFO Parameters {'track_cost': 7.0, 'weight_node_score': -21.0, 'selection_constant': 6.0, 'weight_division': -11.0, 'division_constant': 5.9535746121046165, 'weight_child': 1.0, 'weight_continuation': -1.0, 'weight_edge_score': 0.35, 'block_size': [15, 512, 512, 712], 'context': [2, 100, 100, 100], 'max_cell_move': 45, 'feature_func': 'noop', 'val': False, 'cell_cycle_key': {'$exists': False}, 'tag': {'$exists': False}} not yet in collection, adding with id 3\n", - "2022-06-24 13:51:58,351 linajea.utils.candidate_database INFO Resetting solution for parameter ids [3]\n", - "2022-06-24 13:51:58,368 linajea.utils.candidate_database INFO Resetting soln for parameter_ids [3] in roi None took 0 seconds\n", - "2022-06-24 13:51:58,370 linajea.process_blockwise.solve_blockwise INFO Solving in [48:62, -100:305, -100:612, -100:612] (14, 405, 712, 712)\n", - "2022-06-24 13:51:58,370 linajea.process_blockwise.solve_blockwise INFO Sample: /nrs/funke/hirschp/mskcc_emb3\n", - "2022-06-24 13:51:58,371 linajea.process_blockwise.solve_blockwise INFO DB: linajea_celegans_20220623_180937\n", - "2022-06-24 13:51:58,558 linajea.process_blockwise.solve_blockwise INFO Solving in block linajea_solving/1 with read ROI [48:67, -100:612, -100:612, -100:812] (19, 712, 712, 912) and write ROI [50:65, 0:512, 0:512, 0:712] (15, 512, 512, 712)\n" - ] - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "e2e95e47b6c54fe68515425c7453f222", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "linajea_solving â–¶: 0%| | 0/1 [00:00 Date: Wed, 29 Jun 2022 13:53:16 -0400 Subject: [PATCH 194/263] cleanup config, prune to main method --- linajea/config/__init__.py | 26 +-- linajea/config/augment.py | 59 +------ linajea/config/cell_cycle_config.py | 70 -------- linajea/config/cnn_config.py | 187 --------------------- linajea/config/data.py | 29 ---- linajea/config/evaluate.py | 70 -------- linajea/config/general.py | 12 +- linajea/config/job.py | 3 - linajea/config/optimizer.py | 135 +-------------- linajea/config/predict.py | 40 +---- linajea/config/solve.py | 186 ++------------------ linajea/config/tracking_config.py | 17 +- linajea/config/train.py | 52 ++---- linajea/config/train_test_validate_data.py | 112 ------------ linajea/config/unet_config.py | 22 +-- linajea/config/utils.py | 28 +-- 16 files changed, 66 insertions(+), 982 deletions(-) delete mode 100644 linajea/config/cell_cycle_config.py delete mode 100644 linajea/config/cnn_config.py diff --git a/linajea/config/__init__.py b/linajea/config/__init__.py index ebab9ac..2fc1eea 100644 --- a/linajea/config/__init__.py +++ b/linajea/config/__init__.py @@ -8,10 +8,6 @@ augment: defines configuration used for data augmentation during training -cell_cycle_config: - combines other config submodules into config for cell state classifier -cnn_config: - defines configuration used for CNNs (e.g. for cell state classifier) data: defines configuration used for data samples evaluate: @@ -39,28 +35,18 @@ """ # flake8: noqa -from .augment import (AugmentTrackingConfig, - AugmentCellCycleConfig) -from .cell_cycle_config import CellCycleConfig -from .cnn_config import (VGGConfig, - ResNetConfig, - EfficientNetConfig) +from .augment import AugmentTrackingConfig from .data import DataFileConfig -from .evaluate import (EvaluateCellCycleConfig, - EvaluateTrackingConfig) +from .evaluate import EvaluateTrackingConfig from .extract import ExtractConfig from .general import GeneralConfig from .job import JobConfig -from .predict import (PredictCellCycleConfig, - PredictTrackingConfig) +from .predict import PredictTrackingConfig from .solve import (SolveConfig, - SolveParametersMinimalConfig, - SolveParametersNonMinimalConfig) + SolveParametersConfig) from .tracking_config import TrackingConfig -from .train import (TrainTrackingConfig, - TrainCellCycleConfig) -from .train_test_validate_data import (InferenceDataTrackingConfig, - InferenceDataCellCycleConfig) +from .train import TrainTrackingConfig +from .train_test_validate_data import InferenceDataTrackingConfig from .unet_config import UnetConfig from .utils import (dump_config, load_config, diff --git a/linajea/config/augment.py b/linajea/config/augment.py index 6dd9c33..8ff5495 100644 --- a/linajea/config/augment.py +++ b/linajea/config/augment.py @@ -9,7 +9,6 @@ - Gaussian Noise - Speckle Noise - Salt and Pepper Noise - - Jitter Noise - Zoom - Histogram @@ -143,23 +142,6 @@ class AugmentNoiseSaltPepperConfig: """ amount = attr.ib(type=float, default=0.0001) - -@attr.s(kw_only=True) -class AugmentJitterConfig: - """Defines options for Jitter noise augment - - Notes - ----- - Only used in cell state classifier, not in tracking - - Attributes - ---------- - jitter: list of int - How far to shift cell location, one value per dimension, - sampled uniformly from [-j, +j] in each dimension - """ - jitter = attr.ib(type=List[int], default=[0, 3, 3, 3]) - @attr.s(kw_only=True) class AugmentZoomConfig: """Defines options for Zoom augment @@ -235,7 +217,8 @@ class _AugmentConfig: By default all augmentations are turned off if not set otherwise. Use one of the derived classes below depending on the use case - (tracking vs cell state/cycle classifier) + (currently only AugmentTrackingConfig, might be extended in the + future) Notes ----- @@ -277,42 +260,6 @@ class AugmentTrackingConfig(_AugmentConfig): the lower the probability that point is picked, helps to avoid oversampling of dense regions """ - reject_empty_prob = attr.ib(type=float, default=1.0) # (default=1.0?) + reject_empty_prob = attr.ib(type=float, default=1.0) divisions = attr.ib(type=float, default=0.0) point_balance_radius = attr.ib(type=int, default=1) - - -@attr.s(kw_only=True) -class AugmentCellCycleConfig(_AugmentConfig): - """Specialized class for augmentation for cell state/cycle classifier - - Attributes - ---------- - min_key, max_key: str - Which statistic to use for normalization, have to be - precomputed and stored in data_config.toml per sample - E.g. - [stats] - min = 1874 - max = 65535 - mean = 2260 - std = 282 - perc0_01 = 2036 - perc3 = 2087 - perc99_8 = 4664 - perc99_99 = 7206 - norm_min, norm_max: int - Default values used it min/max_key do not exist - jitter: - See AugmentJitterConfig, shift selected point slightly - - Notes - ----- - TODO: move data normalization info outside - """ - min_key = attr.ib(type=str, default=None) - max_key = attr.ib(type=str, default=None) - norm_min = attr.ib(type=int, default=None) - norm_max = attr.ib(type=int, default=None) - jitter = attr.ib(converter=ensure_cls(AugmentJitterConfig), - default=None) diff --git a/linajea/config/cell_cycle_config.py b/linajea/config/cell_cycle_config.py deleted file mode 100644 index 03c2536..0000000 --- a/linajea/config/cell_cycle_config.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Configuration for Cell State/Cycle Classifier - -Combines configuration sections into one big configuration -that can be used to define and create cell state classifier models -""" -import attr - -from .cnn_config import (EfficientNetConfig, - ResNetConfig, - VGGConfig) -from .evaluate import EvaluateCellCycleConfig -from .general import GeneralConfig -from .optimizer import (OptimizerTF1Config, - OptimizerTF2Config, - OptimizerTorchConfig) - -from .predict import PredictCellCycleConfig -from .train_test_validate_data import (TestDataCellCycleConfig, - TrainDataCellCycleConfig, - ValidateDataCellCycleConfig) -from .train import TrainCellCycleConfig -from .utils import (load_config, - ensure_cls) - - -def _model_converter(): - def converter(val): - if val['network_type'].lower() == "vgg": - return VGGConfig(**val) # type: ignore - elif val['network_type'].lower() == "resnet": - return ResNetConfig(**val) # type: ignore - elif val['network_type'].lower() == "efficientnet": - return EfficientNetConfig(**val) # type: ignore - else: - raise RuntimeError("invalid network_type: {}!".format( - val['network_type'])) - return converter - - -@attr.s(kw_only=True) -class CellCycleConfig: - path = attr.ib(type=str) - general = attr.ib(converter=ensure_cls(GeneralConfig)) - model = attr.ib(converter=_model_converter()) - optimizerTF1 = attr.ib(converter=ensure_cls(OptimizerTF1Config), default=None) - optimizerTF2 = attr.ib(converter=ensure_cls(OptimizerTF2Config), default=None) - optimizerTorch = attr.ib(converter=ensure_cls(OptimizerTorchConfig), default=None) - train = attr.ib(converter=ensure_cls(TrainCellCycleConfig)) - train_data = attr.ib(converter=ensure_cls(TrainDataCellCycleConfig)) - test_data = attr.ib(converter=ensure_cls(TestDataCellCycleConfig)) - validate_data = attr.ib(converter=ensure_cls(ValidateDataCellCycleConfig)) - predict = attr.ib(converter=ensure_cls(PredictCellCycleConfig)) - evaluate = attr.ib(converter=ensure_cls(EvaluateCellCycleConfig)) - - @classmethod - def from_file(cls, path): - config_dict = load_config(path) - config_dict["path"] = path - return cls(**config_dict) # type: ignore - - def __attrs_post_init__(self): - assert (int(bool(self.optimizerTF1)) + - int(bool(self.optimizerTF2)) + - int(bool(self.optimizerTorch))) == 1, \ - "please specify only one optimizer config (tf1, tf2, torch)" - - if self.predict is not None and \ - self.train is not None and \ - self.predict.use_swa is None: - self.predict.use_swa = self.train.use_swa diff --git a/linajea/config/cnn_config.py b/linajea/config/cnn_config.py deleted file mode 100644 index d6a7ca5..0000000 --- a/linajea/config/cnn_config.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Configuration used to define CNN architecture - -Currently supports VGG/ResNet and EfficientNet style networks. -""" -from typing import List - -import attr - -from .job import JobConfig -from .utils import (ensure_cls, - _check_nd_shape, - _int_list_validator, - _list_int_list_validator) - - -@attr.s(kw_only=True) -class _CNNConfig: - """Defines base network class with common parameters - - Attributes - ---------- - path_to_script: str - Location of training script - input_shape: list of int - 4d (t+3d) shape (in voxels) of input to network - pad_raw: list of int - Padding added to sample to allow for sampling of cells at the - boundary, 4d (t+3d) - activation: str - Activation function used by the network, given string has to be - supported by the used framework - padding: str - SAME or VALID, type of padding used in convolutions - merge_time_voxel_size: int - When to use 4d convolutions to merge time dimension into - spatial dimensions, based on voxel_size - Voxel_size doubles after each pooling layer, - if voxel_size >= merge_time_voxel_size, insert 4d conv - use_dropout: bool - If dropout (with rate 0.1) is used in conv layers - use_bias: bool - Whether a bias variable is used in conv layer - use_global_pool: bool - Whether global pooling instead of fully-connected layers is - used at the end of the network - use_conv4d: bool - Whether 4d convolutions are used for 4d data or - fourth dimension is interpreted as channels - (similar to RGB data) - num_classes: int - Number of output classes - classes: list of str - Name of each class - class_ids: list of int - ID for each class - class_sampling_weights: list of int - Ratio with which each class is sampled per batch, - does not have to be normalized to sum to 1 - network_type: str - Type of netowrk used, one of ["vgg", "resnet", "efficientnet"] - make_isotropic: bool - Do not pool anisotropic dimension until data is approx isotropic - regularizer_weight: float - Weight for l2 weight regularization - with_polar: bool - For C. elegans, should polar body class be included? - focal_loss: bool - Whether to use focal loss instead of plain cross entropy - classify_dataset: bool - For testing purposes, ignore label and try to decide which - data sample a cell is orginated from - """ - path_to_script = attr.ib(type=str) - # shape -> voxels, size -> world units - input_shape = attr.ib(type=List[int], - validator=[_int_list_validator, - _check_nd_shape(4)], - default=[5, 32, 32, 32]) - pad_raw = attr.ib(type=List[int], - validator=[_int_list_validator, - _check_nd_shape(4)], - default=[3, 30, 30, 30]) - activation = attr.ib(type=str, default='relu') - padding = attr.ib(type=str, default='SAME') - merge_time_voxel_size = attr.ib(type=int, default=1) - use_dropout = attr.ib(type=bool, default=True) - use_batchnorm = attr.ib(type=bool, default=True) - use_bias = attr.ib(type=bool, default=True) - use_global_pool = attr.ib(type=bool, default=True) - use_conv4d = attr.ib(type=bool, default=True) - num_classes = attr.ib(type=int, default=3) - classes = attr.ib(type=List[str], default=["daughter", "mother", "normal"]) - class_ids = attr.ib(type=List[int], default=[2, 1, 0]) - class_sampling_weights = attr.ib(type=List[int], default=[6, 2, 2, 2]) - network_type = attr.ib( - type=str, - validator=attr.validators.in_(["vgg", "resnet", "efficientnet"])) - make_isotropic = attr.ib(type=int, default=False) - regularizer_weight = attr.ib(type=float, default=None) - with_polar = attr.ib(type=int, default=False) - focal_loss = attr.ib(type=bool, default=False) - classify_dataset = attr.ib(type=bool, default=False) - -@attr.s(kw_only=True) -class VGGConfig(_CNNConfig): - """Specialized class for VGG style networks - - Attributes - ---------- - num_fmaps: int - Number of channels to create in first convolution - fmap_inc_factors: list of int - By which factor to increase number of channels during each - pooling step, number of values depends on number of pooling - steps - downsample_factors: list of list of int - By which factor to downsample during pooling, one value per - dimension per pooling step - kernel_sizes: list of list of int - Size of convolutional kernels, length of outer list depends - on number of pooling steps, length of inner list depends on - number of convolutions per step - net_name: str - Name of network - """ - num_fmaps = attr.ib(type=int, default=12) - fmap_inc_factors = attr.ib(type=List[int], - validator=_int_list_validator, - default=[2, 2, 2, 1]) - downsample_factors = attr.ib(type=List[List[int]], - validator=_list_int_list_validator, - default=[[2, 2, 2], - [2, 2, 2], - [2, 2, 2], - [1, 1, 1]]) - kernel_sizes = attr.ib(type=List[List[int]], - validator=_list_int_list_validator, - default=[[3, 3], - [3, 3], - [3, 3, 3, 3], - [3, 3, 3, 3]]) - fc_size = attr.ib(type=int, default=512) - net_name = attr.ib(type=str, default="vgg") - - -@attr.s(kw_only=True) -class ResNetConfig(_CNNConfig): - """Specialized class for ResNet style networks - - Attributes - ---------- - net_name: - Name of network - resnet_size: str - Size of network, one of ["18", "34", "50", "101", None] - num_blocks: list of int - If resnet_size is None, use this many residual blocks per step - use_bottleneck: - If resnet_size is None, if bottleneck style blocks are used - num_fmaps: str - Number of feature maps used per step (typically 4 steps) - """ - net_name = attr.ib(type=str, default="resnet") - resnet_size = attr.ib(type=str, default=None) - num_blocks = attr.ib(type=List[int], - validator=_int_list_validator, - default=[2, 2, 2, 2]) - use_bottleneck = attr.ib(type=bool, default=False) - num_fmaps = attr.ib(type=List[int], - validator=_int_list_validator, - default=[16, 32, 64, 96]) - - -@attr.s(kw_only=True) -class EfficientNetConfig(_CNNConfig): - """Specialized class for EfficientNet style networks - - Attributes - ---------- - net_name: - Name of network - efficientnet_size: - Which efficient net size to use, - one of "B0" to "B10" - """ - net_name = attr.ib(type=str, default="efficientnet") - efficientnet_size = attr.ib(type=str, default="B0") diff --git a/linajea/config/data.py b/linajea/config/data.py index cf0ca02..0ff4955 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -48,14 +48,11 @@ class DataFileConfig: Size of data/ROI contained in file, determined automatically file_voxel_size: list of int Voxel size of data in file, determined automatically - file_track_range: list of int - deprecated """ filename = attr.ib(type=str) group = attr.ib(type=str, default=None) file_roi = attr.ib(default=None) file_voxel_size = attr.ib(default=None) - file_track_range = attr.ib(type=List[int], default=None) def __attrs_post_init__(self): """Read voxel size and ROI from file @@ -104,36 +101,10 @@ def __attrs_post_init__(self): offset=data_config['general']['offset'], shape=[s*v for s,v in zip(data_config['general']['shape'], self.file_voxel_size)]) # type: ignore - self.file_track_range = data_config['general'].get('track_range') if self.group is None: self.group = data_config['general']['group'] -@attr.s(kw_only=True) -class DataDBMetaConfig: - """Defines a configuration uniquely identifying a database - - Attributes - ---------- - setup_dir: str - Name of the used setup - checkpoint: int - Which model checkpoint was used for prediction - cell_score_threshold: float - Which cell score threshold was used during prediction - voxel_size: list of int - What is the voxel size of the data? - - Notes - ----- - TODO add roi? remove voxel_size? - """ - setup_dir = attr.ib(type=str, default=None) - checkpoint = attr.ib(type=int) - cell_score_threshold = attr.ib(type=float) - voxel_size = attr.ib(type=List[int], default=None) - - @attr.s(kw_only=True) class DataSourceConfig: """Defines a complete data source diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 5c194fa..b266de4 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -1,7 +1,5 @@ """Configuration used for evaluation """ -from typing import List - import attr from .data import DataROIConfig @@ -63,25 +61,6 @@ class EvaluateParametersTrackingConfig: filter_short_tracklets_len = attr.ib(type=int, default=-1) ignore_one_off_div_errors = attr.ib(type=bool, default=False) fn_div_count_unconnected_parent = attr.ib(type=bool, default=True) - # deprecated - frames = attr.ib(type=List[int], default=None) - # deprecated - frame_start = attr.ib(type=int, default=None) - # deprecated - frame_end = attr.ib(type=int, default=None) - # deprecated - limit_to_roi_offset = attr.ib(type=List[int], default=None) - # deprecated - limit_to_roi_shape = attr.ib(type=List[int], default=None) - # deprecated - sparse = attr.ib(type=bool) - - def __attrs_post_init__(self): - if self.frames is not None and \ - self.frame_start is None and self.frame_end is None: - self.frame_start = self.frames[0] - self.frame_end = self.frames[1] - self.frames = None def valid(self): return {key: val @@ -124,52 +103,3 @@ class EvaluateTrackingConfig(_EvaluateConfig): """ from_scratch = attr.ib(type=bool, default=False) parameters = attr.ib(converter=ensure_cls(EvaluateParametersTrackingConfig)) - - -@attr.s(kw_only=True) -class EvaluateParametersCellCycleConfig: - """Defines a set of evaluation parameters for the cell state classifier - - Attributes - ---------- - matching_threshold: int - How far can a GT annotation and a predicted object be apart but - still be matched to each other. - roi: DataROIConfig - Which ROI should be evaluated? - """ - matching_threshold = attr.ib() - roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) - - -@attr.s(kw_only=True) -class EvaluateCellCycleConfig(_EvaluateConfig): - """Defines specialized class for configuration of cell state evaluation - - Attributes - ---------- - max_samples: int - maximum number of samples to evaluate, deprecated - metric: str - which metric to use, deprecated - one_off: bool - Check for one-frame-off divisions (and do not count them as errors) - prob_threshold: float - Ignore predicted objects with a lower score - dry_run: bool - Do not write results to database - find_fn: bool - Do not run normal evaluation but locate missing objects/FN - force_eval: bool - Run evaluation even if results exist already, deprecated - parameters: EvaluateParametersCellCycleConfig - Which evaluation parameters to use - """ - max_samples = attr.ib(type=int) - metric = attr.ib(type=str) - one_off = attr.ib(type=bool) - prob_threshold = attr.ib(type=float) - dry_run = attr.ib(type=bool) - find_fn = attr.ib(type=bool) - force_eval = attr.ib(type=bool, default=False) - parameters = attr.ib(converter=ensure_cls(EvaluateParametersCellCycleConfig)) diff --git a/linajea/config/general.py b/linajea/config/general.py index f005d1b..5e13893 100644 --- a/linajea/config/general.py +++ b/linajea/config/general.py @@ -20,8 +20,6 @@ class GeneralConfig: with existing CandidateDatabase (no training/prediction) db_host: str Address of the mongodb server, by default a local server is assumed - singularity_image: str, optional - Which singularity image to use, deprecated sparse: bool Is the ground truth sparse (not every instance is annotated) logging: int @@ -30,18 +28,16 @@ class GeneralConfig: seed: int Which random seed to use, for replication of experiments, experimental, not used everywhere yet + tag: str, optional + Tag for experiment, can be used for debugging purposes """ # set via post_init hook setup_dir = attr.ib(type=str, default=None) db_host = attr.ib(type=str, default="mongodb://localhost:27017") - singularity_image = attr.ib(type=str, default=None) sparse = attr.ib(type=bool, default=True) - two_frame_edges = attr.ib(type=bool, default=False) - tag = attr.ib(type=str, default=None) - seed = attr.ib(type=int) logging = attr.ib(type=int) - subsampling = attr.ib(type=int, default=None) - subsampling_seed = attr.ib(type=int, default=None) + seed = attr.ib(type=int) + tag = attr.ib(type=str, default=None) def __attrs_post_init__(self): if self.setup_dir is not None: diff --git a/linajea/config/job.py b/linajea/config/job.py index 90253a1..6d9f5f5 100644 --- a/linajea/config/job.py +++ b/linajea/config/job.py @@ -15,8 +15,6 @@ class JobConfig: Which HPC job queue to use (for lsf) lab: Under which budget/account the job should run (for lsf) - singularity_image: str - Which singularity image should be used, deprecated run_on: str on which type of (hpc) system should the job be run one of: local, lsf, slurm, gridengine @@ -27,7 +25,6 @@ class JobConfig: num_workers = attr.ib(type=int, default=1) queue = attr.ib(type=str, default="local") lab = attr.ib(type=str, default=None) - singularity_image = attr.ib(type=str, default=None) run_on = attr.ib(type=str, default="local", validator=attr.validators.in_(["local", "lsf", diff --git a/linajea/config/optimizer.py b/linajea/config/optimizer.py index 0d9897f..19dfc57 100644 --- a/linajea/config/optimizer.py +++ b/linajea/config/optimizer.py @@ -1,7 +1,7 @@ """Configuration used to define the optimizer used during training Which options are available depends on the framework used, -usually pytorch or tensorflow +currently only pytorch """ from typing import List @@ -10,139 +10,6 @@ from .utils import ensure_cls -@attr.s(kw_only=True) -class OptimizerTF1ArgsConfig: - """Defines the positional (args) arguments for a tf1 optimizer - - Attributes - ---------- - weight_decay: float - Rate of l2 weight decay - learning_rate: float - Which learning rate to use, fixed value - momentum: float - Which momentum to use, fixed value - """ - weight_decay = attr.ib(type=float, default=None) - learning_rate = attr.ib(type=float, default=None) - momentum = attr.ib(type=float, default=None) - -@attr.s(kw_only=True) -class OptimizerTF1KwargsConfig: - """Defines the keyword (kwargs) arguments for a tf1 optimizer - - If not set, framework defaults are used. - - Attributes - ---------- - learning_rate: float - Which learning rate to use, fixed value - beta1: float - Beta1 parameter of Adam optimizer - beta2: float - Beta2 parameter of Adam optimzer - epsilon: float - Epsilon parameter of Adam optimizer - - Notes - ----- - Extend list of attributes for other optimizers - """ - learning_rate = attr.ib(type=float, default=None) - beta1 = attr.ib(type=float, default=None) - beta2 = attr.ib(type=float, default=None) - epsilon = attr.ib(type=float, default=None) - -@attr.s(kw_only=True) -class OptimizerTF1Config: - """Defines which tensorflow1 optimizer to use - - Attributes - ---------- - optimizer: str - Name of tf1 optimizer class to use - lr_schedule: str - What type of learning rate schedule to use, deprecated - args: OptimizerTF1ArgsConfig - Which args should be passed to optimizer constructor - kwargs: OptimizerTF1KwargsConfig - Which kwargs should be passed to optimizer constructor - - Notes - ----- - Example on how to create optimzier: - opt = getattr(tf.train, config.optimizerTF1.optimizer)( - *config.optimizerTF1.get_args(), - **config.optimizerTF1.get_kwargs()) - """ - optimizer = attr.ib(type=str, default="AdamOptimizer") - lr_schedule = attr.ib(type=str, default=None) - args = attr.ib(converter=ensure_cls(OptimizerTF1ArgsConfig)) - kwargs = attr.ib(converter=ensure_cls(OptimizerTF1KwargsConfig)) - - def get_args(self): - """Get list of positional parameters""" - return [v for v in attr.astuple(self.args) if v is not None] - - def get_kwargs(self): - """Get dict of keyword parameters""" - return {a:v for a,v in attr.asdict(self.kwargs).items() if v is not None} - - -@attr.s(kw_only=True) -class OptimizerTF2KwargsConfig: - """Defines the keyword (kwargs) arguments for a tf2 optimizer - - If not set, framework defaults are used. - - Attributes - ---------- - beta_1: float - Beta1 parameter of Adam optimizer - beta_2: float - Beta2 parameter of Adam optimzer - epsilon: float - Epsilon parameter of Adam optimizer - learning_rate: float - Which learning rate to use, fixed value - momentum: float - Which momentum to use, fixed value - - Notes - ----- - Extend list of attributes for other optimizers - """ - beta_1 = attr.ib(type=float, default=None) - beta_2 = attr.ib(type=float, default=None) - epsilon = attr.ib(type=float, default=None) - learning_rate = attr.ib(type=float, default=None) - momentum = attr.ib(type=float, default=None) - -@attr.s(kw_only=True) -class OptimizerTF2Config: - """Defines which tensorflow1 optimizer to use - - Attributes - ---------- - optimizer: str - Name of tf2 optimizer class to use - kwargs: OptimizerTF2KwargsConfig - Which kwargs should be passed to optimizer constructor - - Notes - ----- - Example on how to create optimzier: - opt = getattr(tf.keras.optimizers, config.optimizerTF2.optimizer)( - **config.optimizerTF2.get_kwargs()) - """ - optimizer = attr.ib(type=str, default="Adam") - kwargs = attr.ib(converter=ensure_cls(OptimizerTF2KwargsConfig)) - - def get_kwargs(self): - """Get dict of keyword parameters""" - return {a:v for a,v in attr.asdict(self.kwargs).items() if v is not None} - - @attr.s(kw_only=True) class OptimizerTorchKwargsConfig: """Defines the keyword (kwargs) arguments for a pytorch optimizer diff --git a/linajea/config/predict.py b/linajea/config/predict.py index f2992ca..7a40f28 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -16,8 +16,6 @@ class _PredictConfig: job: JobConfig HPC cluster parameters, default constructed (executed locally) if not supplied - path_to_script: str - Which script should be called for prediction, deprecated use_swa: bool If also used during training, should the Stochastic Weight Averaging model be used for prediction, check TrainConfig for more information @@ -27,7 +25,6 @@ class _PredictConfig: """ job = attr.ib(converter=ensure_cls(JobConfig), default=attr.Factory(JobConfig)) - path_to_script = attr.ib(type=str) use_swa = attr.ib(type=bool, default=None) normalization = attr.ib(converter=ensure_cls(NormalizeConfig), default=None) @@ -38,9 +35,6 @@ class PredictTrackingConfig(_PredictConfig): Attributes ---------- - path_to_script_db_from_zarr: str - Which script to use to write from an already predicted volume - directly to database, instead of predicting again write_to_zarr: bool Write output to zarr volume, e.g. for visualization write_to_db: bool @@ -52,43 +46,21 @@ class PredictTrackingConfig(_PredictConfig): (otherwise used to store which blocks have already been predicted) processes_per_worker: int How many processes each worker can use (for parallel data loading) - output_zarr_prefix: str + output_zarr_dir: str Where zarr should be stored """ - path_to_script_db_from_zarr = attr.ib(type=str, default=None) write_to_zarr = attr.ib(type=bool, default=False) write_to_db = attr.ib(type=bool, default=True) write_db_from_zarr = attr.ib(type=bool, default=False) no_db_access = attr.ib(type=bool, default=False) processes_per_worker = attr.ib(type=int, default=1) - output_zarr_prefix = attr.ib(type=str, default=".") + output_zarr_dir = attr.ib(type=str, default=".") def __attrs_post_init__(self): """verify that combination of supplied parameters is valid""" assert self.write_to_zarr or self.write_to_db, \ "prediction not written, set write_to_zarr or write_to_db to true!" - assert not self.write_db_from_zarr or \ - self.path_to_script_db_from_zarr, \ - "supply path_to_script_db_from_zarr if write_db_from_zarr is used!" - assert not ((self.write_to_db or self.write_db_from_zarr) and self.no_db_access), \ - "no_db_access can only be set if no data is written to db (it then disables db done checks for write to zarr)" - -@attr.s(kw_only=True) -class PredictCellCycleConfig(_PredictConfig): - """Defines specialized class for configuration of cell state prediction - - Attributes - ---------- - batch_size: int - How many samples should be in each mini-batch - max_samples: int - Limit how many samples to predict, all if set to None - prefix: str - If results is stored in database, define prefix for attribute name - test_time_reps: int - Do test time augmentation, how many repetitions? - """ - batch_size = attr.ib(type=int) - max_samples = attr.ib(type=int, default=None) - prefix = attr.ib(type=str, default="") - test_time_reps = attr.ib(type=int, default=1) + assert not ((self.write_to_db or self.write_db_from_zarr) and + self.no_db_access), \ + ("no_db_access can only be set if no data is written " + "to db (it then disables db done checks for write to zarr)") diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 031b08d..3beeaad 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -11,57 +11,14 @@ from .data import DataROIConfig from .job import JobConfig from .utils import (ensure_cls, + ensure_cls_list load_config) logger = logging.getLogger(__name__) -def _convert_solve_params_list(): - """Auxiliary setup function to distinguish between - minimal and non-minimal ILP parameters""" - def converter(vals): - if vals is None: - return None - if isinstance(vals, str) and vals.endswith(".toml"): - tmp_config = load_config(vals) - vals = tmp_config["solve"]["parameters"] - if not isinstance(vals, list): - vals = [vals] - converted = [] - for val in vals: - if isinstance(val, SolveParametersMinimalConfig): - converted.append(val) - elif isinstance(val, SolveParametersNonMinimalConfig): - converted.append(val) - else: - if "track_cost" in val: - converted.append(SolveParametersMinimalConfig(**val)) - else: - converted.append(SolveParametersNonMinimalConfig(**val)) - return converted - return converter - - -def _convert_solve_search_params(): - """Auxiliary setup function to distinguish between - minimal and non-minimal ILP parameters""" - def converter(vals): - if vals is None: - return None - - if isinstance(vals, SolveParametersMinimalSearchConfig) or \ - isinstance(vals, SolveParametersNonMinimalSearchConfig): - return vals - else: - if "track_cost" in vals: - return SolveParametersMinimalSearchConfig(**vals) - else: - return SolveParametersNonMinimalSearchConfig(**vals) - return converter - - @attr.s(kw_only=True) -class SolveParametersMinimalConfig: +class SolveParametersConfig: """Defines a set of ILP hyperparameters Attributes @@ -70,8 +27,6 @@ class SolveParametersMinimalConfig: division_constant, weight_child, weight_continuation, weight_edge_score: float main ILP hyperparameters - cell_cycle_key: str - key defining which cell cycle classifier to use block_size: list of int ILP is solved in blocks, defines size of each block context: list of int @@ -105,7 +60,6 @@ class SolveParametersMinimalConfig: weight_child = attr.ib(type=float, default=0.0) weight_continuation = attr.ib(type=float, default=0.0) weight_edge_score = attr.ib(type=float) - cell_cycle_key = attr.ib(type=str, default=None) block_size = attr.ib(type=List[int]) context = attr.ib(type=List[int]) max_cell_move = attr.ib(type=int, default=None) @@ -142,7 +96,7 @@ def query(self): @attr.s(kw_only=True) -class SolveParametersMinimalSearchConfig: +class SolveParametersSearchConfig: """Defines ranges/sets of ILP hyperparameters for a parameter search Can be used for both random search (search by selecting random @@ -151,7 +105,7 @@ class SolveParametersMinimalSearchConfig: Notes ----- - For description of main attributes see SolveParametersMinimalConfig + For description of main attributes see SolveParametersConfig Attributes ---------- @@ -169,7 +123,6 @@ class SolveParametersMinimalSearchConfig: weight_child = attr.ib(type=List[float], default=None) weight_continuation = attr.ib(type=List[float], default=None) weight_edge_score = attr.ib(type=List[float]) - cell_cycle_key = attr.ib(type=str, default=None) block_size = attr.ib(type=List[List[int]]) context = attr.ib(type=List[List[int]]) max_cell_move = attr.ib(type=List[int], default=None) @@ -178,89 +131,14 @@ class SolveParametersMinimalSearchConfig: num_configs = attr.ib(type=int, default=None) -@attr.s(kw_only=True) -class SolveParametersNonMinimalConfig: - """Defines ILP hyperparameters - - Notes - ----- - old set of parameters, not used anymore, for backwards compatibility - for basic info see SolveParametersMinimalConfig - """ - cost_appear = attr.ib(type=float) - cost_disappear = attr.ib(type=float) - cost_split = attr.ib(type=float) - threshold_node_score = attr.ib(type=float) - weight_node_score = attr.ib(type=float) - threshold_edge_score = attr.ib(type=float) - weight_prediction_distance_cost = attr.ib(type=float) - use_cell_state = attr.ib(type=str, default=None) - use_cell_cycle_indicator = attr.ib(type=str, default=False) - threshold_split_score = attr.ib(type=float, default=None) - threshold_is_normal_score = attr.ib(type=float, default=None) - threshold_is_daughter_score = attr.ib(type=float, default=None) - cost_daughter = attr.ib(type=float, default=None) - cost_normal = attr.ib(type=float, default=None) - prefix = attr.ib(type=str, default=None) - block_size = attr.ib(type=List[int]) - context = attr.ib(type=List[int]) - max_cell_move = attr.ib(type=int, default=None) - roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) - - def valid(self): - return {key: val - for key, val in attr.asdict(self).items() - if val is not None} - - def query(self): - params_dict_valid = self.valid() - params_dict_none = {key: {"$exists": False} - for key, val in attr.asdict(self).items() - if val is None} - query = {**params_dict_valid, **params_dict_none} - return query - - -@attr.s(kw_only=True) -class SolveParametersNonMinimalSearchConfig: - """Defines ranges/sets of ILP hyperparameters for a parameter search - - Notes - ----- - old set of parameters, not used anymore, for backwards compatibility - for basic info see SolveParametersMinimalSearchConfig - """ - cost_appear = attr.ib(type=List[float]) - cost_disappear = attr.ib(type=List[float]) - cost_split = attr.ib(type=List[float]) - threshold_node_score = attr.ib(type=List[float]) - weight_node_score = attr.ib(type=List[float]) - threshold_edge_score = attr.ib(type=List[float]) - weight_prediction_distance_cost = attr.ib(type=List[float]) - use_cell_state = attr.ib(type=List[float], default=None) - threshold_split_score = attr.ib(type=List[float], default=None) - threshold_is_normal_score = attr.ib(type=List[float], default=None) - threshold_is_daughter_score = attr.ib(type=List[float], default=None) - cost_daughter = attr.ib(type=List[float], default=None) - cost_normal = attr.ib(type=List[float], default=None) - prefix = attr.ib(type=str, default=None) - block_size = attr.ib(type=List[List[int]]) - context = attr.ib(type=List[List[int]]) - # max_cell_move: currently use edge_move_threshold from extract - max_cell_move = attr.ib(type=List[int], default=None) - num_configs = attr.ib(type=int, default=None) - -def write_solve_parameters_configs(parameters_search, non_minimal, grid): +def write_solve_parameters_configs(parameters_search, grid): """Create list of ILP hyperparameter sets based on configuration Args ---- - parameters_search: SolveParametersMinimalSearchConfig + parameters_search: SolveParametersSearchConfig Parameter search object that is used to create list of individual sets of parameters - non_minimal: bool - Create minimal (new) or non minimal (old, deprecated) set of - parameters grid: bool Do grid search or random search @@ -327,13 +205,6 @@ def write_solve_parameters_configs(parameters_search, non_minimal, grid): conf[k] = value search_configs.append(conf) else: - if params.get('cell_cycle_key') == '': - params['cell_cycle_key'] = None - elif isinstance(params.get('cell_cycle_key'), list) and \ - '' in params['cell_cycle_key']: - params['cell_cycle_key'] = [k if k != '' else None - for k in params['cell_cycle_key']] - search_configs = [ dict(zip(params.keys(), x)) for x in itertools.product(*params.values())] @@ -345,12 +216,8 @@ def write_solve_parameters_configs(parameters_search, non_minimal, grid): configs = [] for config_vals in search_configs: logger.debug("Config vals %s" % str(config_vals)) - if non_minimal: - configs.append(SolveParametersNonMinimalConfig( - **config_vals)) # type: ignore - else: - configs.append(SolveParametersMinimalConfig( - **config_vals)) # type: ignore + configs.append(SolveParametersConfig( + **config_vals)) # type: ignore return configs @@ -366,28 +233,17 @@ class SolveConfig: if not supplied from_scratch: bool If solution should be recomputed if it already exists - parameters: SolveParametersMinimalConfig + parameters: SolveParametersConfig Fixed set of ILP parameters - parameters_search_grid, parameters_search_random: SolveParametersMinimalSearchConfig + parameters_search_grid, parameters_search_random: SolveParametersSearchConfig Ranges/sets per ILP parameter to create parameter search - non_minimal: bool - If old (non-minimal) parameters should be used, deprecated greedy: bool Do not use ILP for solving, greedy nearest neighbor tracking - write_struct_svm: str - If not None, do not solve but write files required for StructSVM - parameter search check_node_close_to_roi: bool If set, nodes close to roi border do not pay certain costs (as they can move outside of the field of view) - add_node_density_constraints: bool - Prevent multiple nodes within certain range, useful if size of - nuclei changes drastically over time, deprecated timeout: int Time the solver has to find a solution - masked_nodes: str - Tag that can be used to mask certain nodes (i.e. discard them), - deprecated clip_low_score: float Discard nodes with score lower than this value; Only useful if lower than threshold used during prediction @@ -404,18 +260,15 @@ class SolveConfig: job = attr.ib(converter=ensure_cls(JobConfig), default=attr.Factory(JobConfig)) from_scratch = attr.ib(type=bool, default=False) - parameters = attr.ib(converter=_convert_solve_params_list(), default=None) - parameters_search_grid = attr.ib(converter=_convert_solve_search_params(), - default=None) - parameters_search_random = attr.ib(converter=_convert_solve_search_params(), - default=None) - non_minimal = attr.ib(type=bool, default=False) + parameters = attr.ib(converter=ensure_cls_list(SolveParametersConfig), + default=None) + parameters_search_grid = attr.ib( + converter=ensure_cls(SolveParametersSearchConfig), default=None) + parameters_search_random = attr.ib( + converter=ensure_cls(SolveParametersSearchConfig), default=None) greedy = attr.ib(type=bool, default=False) - write_struct_svm = attr.ib(type=str, default=None) check_node_close_to_roi = attr.ib(type=bool, default=True) - add_node_density_constraints = attr.ib(type=bool, default=False) timeout = attr.ib(type=int, default=120) - masked_nodes = attr.ib(type=str, default=None) clip_low_score = attr.ib(type=float, default=None) grid_search = attr.ib(type=bool, default=False) random_search = attr.ib(type=bool, default=False) @@ -452,8 +305,7 @@ def __attrs_post_init__(self): "if random search activated" parameters_search = self.parameters_search_random self.parameters = write_solve_parameters_configs( - parameters_search, non_minimal=self.non_minimal, - grid=self.grid_search) + parameters_search, grid=self.grid_search) if self.greedy: config_vals = { @@ -468,9 +320,7 @@ def __attrs_post_init__(self): "block_size": [-1, -1, -1, -1], "context": [-1, -1, -1, -1] } - if self.parameters[0].cell_cycle_key is not None: - config_vals['cell_cycle_key'] = self.parameters[0].cell_cycle_key - self.parameters = [SolveParametersMinimalConfig(**config_vals)] + self.parameters = [SolveParametersConfig(**config_vals)] # block size and context must be the same for all parameters! block_size = self.parameters[0].block_size context = self.parameters[0].context diff --git a/linajea/config/tracking_config.py b/linajea/config/tracking_config.py index fce6f3d..5ff920c 100644 --- a/linajea/config/tracking_config.py +++ b/linajea/config/tracking_config.py @@ -12,9 +12,7 @@ from .evaluate import EvaluateTrackingConfig from .extract import ExtractConfig from .general import GeneralConfig -from .optimizer import (OptimizerTF1Config, - OptimizerTF2Config, - OptimizerTorchConfig) +from .optimizer import OptimizerTorchConfig from .predict import PredictTrackingConfig from .solve import SolveConfig from .train_test_validate_data import (InferenceDataTrackingConfig, @@ -39,8 +37,6 @@ class TrackingConfig: General configuration parameters model: UnetConfig Parameters defining the use U-Net - optimizerTF1: OptimizerTF1Config - optimizerTF2: OptimizerTF2Config optimizerTorch: OptimizerTorchConfig Parameters defining the optimizer used during training train: TrainTrackingConfig @@ -63,10 +59,6 @@ class TrackingConfig: path = attr.ib(type=str) general = attr.ib(converter=ensure_cls(GeneralConfig)) model = attr.ib(converter=ensure_cls(UnetConfig), default=None) - optimizerTF1 = attr.ib(converter=ensure_cls(OptimizerTF1Config), - default=None) - optimizerTF2 = attr.ib(converter=ensure_cls(OptimizerTF2Config), - default=None) optimizerTorch = attr.ib(converter=ensure_cls(OptimizerTorchConfig), default=None) train = attr.ib(converter=ensure_cls(TrainTrackingConfig), default=None) @@ -99,14 +91,9 @@ def __attrs_post_init__(self): At most one optimizer configuration can be supplied If use_swa is not set for prediction step, use value from train - If normalization is not set for prediction step, use value from train + If normalization is not set for prediction step, use value from train Verify that ROI is set at some level for each data source """ - assert (int(bool(self.optimizerTF1)) + - int(bool(self.optimizerTF2)) + - int(bool(self.optimizerTorch))) <= 1, \ - "please specify only one optimizer config (tf1, tf2, torch)" - if self.predict is not None and \ self.train is not None and \ self.predict.use_swa is None: diff --git a/linajea/config/train.py b/linajea/config/train.py index 3851707..e4e23f4 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -5,7 +5,6 @@ import attr from .augment import (AugmentTrackingConfig, - AugmentCellCycleConfig, NormalizeConfig) from .train_test_validate_data import TrainDataTrackingConfig from .job import JobConfig @@ -27,9 +26,6 @@ class _TrainConfig: Attributes ---------- - path_to_script: str - Path to the training python script to be used - (can e.g. be dependent on the type of data) job: JobConfig HPC cluster parameters cache_size: int @@ -46,7 +42,7 @@ class _TrainConfig: use_swa: bool Use Stochastic Weight Averaging, in some preliminary tests we observed improvements for the tracking networks but not for - the cell cycle networks. Cheap to compute. Keeps two copies of + others. Cheap to compute. Keeps two copies of the model weights in memory, one is updated as usual, the other is a weighted average of the main weights every x steps. Has to be supported by the respective gunpowder train node. @@ -64,14 +60,12 @@ class _TrainConfig: normalization: NormalizeConfig Parameters defining which data normalization type to use """ - path_to_script = attr.ib(type=str) job = attr.ib(converter=ensure_cls(JobConfig)) cache_size = attr.ib(type=int) max_iterations = attr.ib(type=int) checkpoint_stride = attr.ib(type=int) - snapshot_stride = attr.ib(type=int) +ap snapshot_stride = attr.ib(type=int) profiling_stride = attr.ib(type=int) - use_tf_data = attr.ib(type=bool, default=False) use_auto_mixed_precision = attr.ib(type=bool, default=False) use_swa = attr.ib(type=bool, default=False) swa_every_it = attr.ib(type=bool, default=False) @@ -93,60 +87,40 @@ class TrainTrackingConfig(_TrainConfig): Attributes ---------- - parent_radius: list of float - Radius around each cell in which GT movement/parent vectors + object_radius: list of float + Radius around each object in which GT movement vectors are drawn, in world units, one value per dimension (radius for binary map -> *2 to get diameter, optional if annotations contain radius/use_radius = True) move_radius: float - Extend ROI by this much context to ensure all parent cells are + Extend ROI by this much context to ensure all parent objects are inside the ROI, in world units rasterize_radius: list of float Standard deviation of Gauss kernel used to draw Gaussian blobs - for cell indicator network, one value per dimension + for object indicator network, one value per dimension (*4 to get approx. width of blob, for 5x anisotropic data set value in z to 5 for it to be in 3 slices, optional if annotations contain radius/use_radius = True) augment: AugmentTrackingConfig Defines data augmentations used during training, see AugmentTrackingConfig - parent_vectors_loss_transition_factor: float - We transition from computing the movement/parent vector loss for + movement_vectors_loss_transition_factor: float + We transition from computing the movement vector loss for all pixels smoothly to computing it only on the peak pixels of - the cell indicator map, this value determines the speed of the + the object indicator map, this value determines the speed of the transition - parent_vectors_loss_transition_offset: int + movement_vectors_loss_transition_offset: int This value marks the training step when the transition blending factor is 0.5 use_radius: dict of int: int If the GT annotations contain radii, enable this to use them instead of the fixed values defined above - cell_density: bool - Predict a pixelwise cell density value, deprecated - """ - parent_radius = attr.ib(type=List[float]) + object_radius = attr.ib(type=List[float]) move_radius = attr.ib(type=float) rasterize_radius = attr.ib(type=List[float]) augment = attr.ib(converter=ensure_cls(AugmentTrackingConfig)) - parent_vectors_loss_transition_factor = attr.ib(type=float, default=0.01) - parent_vectors_loss_transition_offset = attr.ib(type=int, default=20000) + object_vectors_loss_transition_factor = attr.ib(type=float, default=0.01) + object_vectors_loss_transition_offset = attr.ib(type=int, default=20000) use_radius = attr.ib(type=Dict[int, int], default=None, converter=use_radius_converter()) - cell_density = attr.ib(type=bool, default=None) - - -@attr.s(kw_only=True) -class TrainCellCycleConfig(_TrainConfig): - """Defines specialized class for configuration of tracking training - - Attributes - ---------- - batch_size: int - Size of mini-batches used during training - augment: AugmentCellCycleConfig - Defines data augmentations used during cell classifier training, - see AugmentCellCycleConfig - """ - batch_size = attr.ib(type=int) - augment = attr.ib(converter=ensure_cls(AugmentCellCycleConfig)) diff --git a/linajea/config/train_test_validate_data.py b/linajea/config/train_test_validate_data.py index 2ec67b6..c617364 100644 --- a/linajea/config/train_test_validate_data.py +++ b/linajea/config/train_test_validate_data.py @@ -13,7 +13,6 @@ import daisy from .data import (DataSourceConfig, - DataDBMetaConfig, DataROIConfig) from .utils import (ensure_cls, ensure_cls_list) @@ -68,25 +67,6 @@ def __attrs_post_init__(self): roi = daisy.Roi(offset=d.roi.offset, shape=d.roi.shape) roi = roi.intersect(file_roi) - if d.datafile.file_track_range is not None: - if isinstance(d.datafile.file_track_range, dict): - if os.path.isdir(d.datafile.filename): - # TODO check this for training - begin_frame, end_frame = list(d.datafile.file_track_range.values())[0] - for _, v in d.datafile.file_track_range.items(): - if v[0] < begin_frame: - begin_frame = v[0] - if v[1] > end_frame: - end_frame = v[1] - else: - begin_frame, end_frame = d.datafile.file_track_range[ - os.path.basename(d.datafile.filename)] - else: - begin_frame, end_frame = d.datafile.file_track_range - track_range_roi = daisy.Roi( - offset=[begin_frame, None, None, None], - shape=[end_frame-begin_frame+1, None, None, None]) - roi = roi.intersect(track_range_roi) d.roi.offset = roi.get_offset() d.roi.shape = roi.get_shape() if d.voxel_size is None: @@ -109,7 +89,6 @@ def __attrs_post_init__(self): class TrainDataTrackingConfig(_DataConfig): """Defines a specialized class for the definition of a training data set """ - # data_sources = attr.ib(converter=ensure_cls_list(DataSourceConfig)) @data_sources.validator def _check_train_data_source(self, attribute, value): """a train data source has to use datafiles and cannot have a database""" @@ -189,94 +168,3 @@ class ValidateDataTrackingConfig(_DataConfig): """ checkpoints = attr.ib(type=List[int], default=[None]) cell_score_threshold = attr.ib(type=float, default=None) - - - -@attr.s(kw_only=True) -class _DataCellCycleConfig(_DataConfig): - """Defines a base class for the definition of a cell state classifier data set - - Attributes - ---------- - use_database: bool - If set, samples are read from database, not from data file - db_meta_info: DataDBMetaConfig - Identifies database to use if use_database is set - skip_predict: bool - Skip prediction step, e.g. if already computed - (enables shortcut in run script) - force_predict: bool - Enforce prediction, even if already done previously - """ - use_database = attr.ib(type=bool) - db_meta_info = attr.ib(converter=ensure_cls(DataDBMetaConfig), default=None) - skip_predict = attr.ib(type=bool, default=False) - force_predict = attr.ib(type=bool, default=False) - - -@attr.s(kw_only=True) -class TrainDataCellCycleConfig(_DataCellCycleConfig): - """Definition of a cell state classifier training data set - """ - pass - - -@attr.s(kw_only=True) -class TestDataCellCycleConfig(_DataCellCycleConfig): - """Definition of a cell state classifier test data set - - Attributes - ---------- - checkpoint: int - Which checkpoint of the trained model should be used? - prob_threshold: float - What is the minimum score of object/node candidates to be considered? - """ - checkpoint = attr.ib(type=int) - prob_threshold = attr.ib(type=float, default=None) - - -@attr.s(kw_only=True) -class ValidateDataCellCycleConfig(_DataCellCycleConfig): - """Definition of a cell state classifier training data set - - Attributes - ---------- - checkpoints: list of int - Which checkpoints of the trained model should be used? - prob_threshold: list of float - What are the minimum scores of object/node candidates to be considered? - - Notes - ----- - Computes the result for every combination of checkpoints and thresholds - """ - checkpoints = attr.ib(type=List[int]) - prob_thresholds = attr.ib(type=List[float], default=None) - - -@attr.s(kw_only=True) -class InferenceDataCellCycleConfig(): - """Definition of a cell state classifier inference data set - - Attributes - ---------- - data_source: DataSourceConfig - Which data source should be used? - checkpoint: int - Which checkpoint of the trained model should be used? - prob_threshold: float - What is the minimum score of object/node candidates to be considered? - use_database: bool - If set, samples are read from database, not from data file - db_meta_info: DataDBMetaConfig - Identifies database to use if use_database is set - force_predict: bool - Enforce prediction, even if already done previously - """ - data_source = attr.ib(converter=ensure_cls(DataSourceConfig)) - checkpoint = attr.ib(type=int, default=None) - prob_threshold = attr.ib(type=float) - use_database = attr.ib(type=bool) - db_meta_info = attr.ib(converter=ensure_cls(DataDBMetaConfig), default=None) - force_predict = attr.ib(type=bool, default=False) diff --git a/linajea/config/unet_config.py b/linajea/config/unet_config.py index b7fb9b7..2724ff1 100644 --- a/linajea/config/unet_config.py +++ b/linajea/config/unet_config.py @@ -19,8 +19,6 @@ class UnetConfig: Attributes ---------- - path_to_script: str - Location of training script train_input_shape, predict_input_shape: list of int 4d (t+3d) shape (in voxels) of input to network fmap_inc_factors: list of int @@ -64,17 +62,13 @@ class UnetConfig: Use very small weight for pixels lower than cutoff in gt map cell_indicator_cutoff: float Cutoff values for weight, in gt_cell_indicator map - chkpt_parents: str - Not used, path to model checkpoint just for movement vectors - chkpt_cell_indicator - Not used, path to model checkpoint just for cell indicator - latent_temp_conv: bool - Apply temporal/4d conv not in the beginning but at bottleneck - train_only_cell_indicator: bool - Only train cell indicator network, not movement vectors + + Notes + ----- + In general, `shape` is in voxels, `size` is in world units, the input + to the network has to be specified in voxels. + (though kernel_size is still in voxels) """ - path_to_script = attr.ib(type=str, default=None) - # shape -> voxels, size -> world units train_input_shape = attr.ib(type=List[int], validator=[_int_list_validator, _check_nd_shape(4)]) @@ -113,7 +107,3 @@ class UnetConfig: num_fmaps = attr.ib(type=int) cell_indicator_weighted = attr.ib(type=bool, default=True) cell_indicator_cutoff = attr.ib(type=float, default=0.01) - chkpt_parents = attr.ib(type=str, default=None) - chkpt_cell_indicator = attr.ib(type=str, default=None) - latent_temp_conv = attr.ib(type=bool, default=False) - train_only_cell_indicator = attr.ib(type=bool, default=False) diff --git a/linajea/config/utils.py b/linajea/config/utils.py index 771c112..6697f03 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -50,7 +50,7 @@ def dump_config(config): Args ---- - config: TrackingConfig or CellCycleConfig or dict + config: TrackingConfig or dict """ if not isinstance(config, dict): config = attr.asdict(config) @@ -80,23 +80,6 @@ def converter(val): return cl(**val) return converter -def ensure_cls_construct_on_none(cl): - """attrs convert to ensure type of value - - If the attribute is an instance of cls, pass, else try constructing. - This way an instance of an attrs config object can be passed or a - dict that can be used to construct such an instance. - """ - def converter(val): - if isinstance(val, cl): - return val - elif val is None: - return cl() - else: - return cl(**val) - return converter - - def ensure_cls_list(cl): """attrs converter to ensure type of values in list @@ -114,6 +97,9 @@ def converter(vals): if vals is None: return None + if isinstance(vals, str) and vals.endswith(".toml"): + vals = load_config(vals) + assert isinstance(vals, list), "list of {} expected ({})".format( cl, vals) converted = [] @@ -223,9 +209,9 @@ def maybe_fix_config_paths_to_machine_and_load(config): config_dict["predict"]["path_to_script_db_from_zarr"].replace( "/groups/funke/home/hirschp/linajea_experiments", paths["HOME"]) - if "output_zarr_prefix" in config_dict["predict"]: - config_dict["predict"]["output_zarr_prefix"] = \ - config_dict["predict"]["output_zarr_prefix"].replace( + if "output_zarr_dir" in config_dict["predict"]: + config_dict["predict"]["output_zarr_dir"] = \ + config_dict["predict"]["output_zarr_dir"].replace( "/nrs/funke/hirschp", paths["DATA"]) dss = [] From 42241ce2faffcada291ff3c7668d6aa5a9ab79cb Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 13:53:35 -0400 Subject: [PATCH 195/263] cleanup evaluation, prune to main method --- linajea/evaluation/__init__.py | 18 +- linajea/evaluation/analyze_candidates.py | 64 +---- linajea/evaluation/analyze_results.py | 337 ++++++++--------------- linajea/evaluation/evaluate.py | 52 +++- linajea/evaluation/evaluate_setup.py | 225 ++------------- linajea/evaluation/evaluator.py | 121 ++++---- linajea/evaluation/match.py | 30 +- linajea/evaluation/match_nodes.py | 32 ++- linajea/evaluation/report.py | 52 +++- linajea/evaluation/validation_metric.py | 91 +++--- 10 files changed, 371 insertions(+), 651 deletions(-) diff --git a/linajea/evaluation/__init__.py b/linajea/evaluation/__init__.py index 38feda2..566a0dd 100644 --- a/linajea/evaluation/__init__.py +++ b/linajea/evaluation/__init__.py @@ -1,21 +1,5 @@ # flake8: noqa -from .evaluate import evaluate from .match import match_edges -from .match_nodes import match_nodes from .evaluate_setup import evaluate_setup -from .evaluator import Evaluator from .report import Report -from .validation_metric import validation_score -from .analyze_results import ( - get_result, get_results, get_best_result, - get_results_sorted, get_best_result_with_config, - get_result_id, get_result_params, get_results_sorted_db, - get_best_result_per_setup, - get_tgmm_results, - get_best_tgmm_result, - get_greedy) -from .analyze_candidates import ( - get_node_recall, - get_edge_recall, - calc_pv_distances) -from .division_evaluation import evaluate_divisions +from .analyze_results import get_results_sorted diff --git a/linajea/evaluation/analyze_candidates.py b/linajea/evaluation/analyze_candidates.py index 5e11294..049cce2 100644 --- a/linajea/evaluation/analyze_candidates.py +++ b/linajea/evaluation/analyze_candidates.py @@ -1,7 +1,7 @@ -from scipy.spatial import cKDTree, distance -import numpy as np import logging +from scipy.spatial import cKDTree, distance + logger = logging.getLogger(__name__) # TODO: Don't use candidate_graphs, networkx is slow and unnecessary for the # candidates. Go straight to pymongo queries, or maybe daisy mongo node/edge @@ -99,63 +99,3 @@ def get_edge_recall( if matched: num_matches += 1 return num_matches, num_gt_edges - - -def sort_nodes_by_frame(graph): - sorted_nodes = {} - for node_id, node in graph.nodes(data=True): - if 't' not in node: - continue - if node['t'] not in sorted_nodes: - sorted_nodes[node['t']] = [] - sorted_nodes[node['t']].append((node_id, node)) - return sorted_nodes - - -def calc_pv_distances( - candidate_graph, - gt_graph, - match_distance): - gt_nodes_by_frame = sort_nodes_by_frame(gt_graph) - cand_nodes_by_frame = sort_nodes_by_frame(candidate_graph) - cand_kd_trees = get_kd_trees_by_frame(candidate_graph) - prediction_distances = [] - baseline_distances = [] - for frame, cand_kd_tree in cand_kd_trees.items(): - if frame not in gt_nodes_by_frame: - logger.warn("Frame %s has cand nodes but no gt nodes" % frame) - continue - gt_nodes = gt_nodes_by_frame[frame] - candidate_nodes = cand_nodes_by_frame[frame] - for gt_node_id, gt_node_info in gt_nodes: - # get location of real parent - parent_edges = list(gt_graph.prev_edges(gt_node_id)) - if len(parent_edges) == 0: - continue - assert len(parent_edges) == 1 - parent_id = parent_edges[0][1] - parent_node = gt_graph.nodes[parent_id] - parent_location = np.array([ - parent_node['z'], - parent_node['y'], - parent_node['x']]) - - # get predicted location of parent - gt_node_location = np.array([ - gt_node_info['z'], - gt_node_info['y'], - gt_node_info['x']]) - distance, index = cand_kd_tree.query( - gt_node_location, k=1, distance_upper_bound=match_distance) - if distance > match_distance: - continue - matched_cand_pv = np.array(candidate_nodes[index]['parent_vector']) - matched_cand_location = np.array(cand_kd_tree.data[index]) - predicted_location = matched_cand_location + matched_cand_pv - prediction_distances.append(np.linalg.norm( - predicted_location - parent_location)) - - baseline_distances.append(np.linalg.norm( - matched_cand_location - parent_location)) - - return prediction_distances, baseline_distances diff --git a/linajea/evaluation/analyze_results.py b/linajea/evaluation/analyze_results.py index 83636e3..547e8b7 100644 --- a/linajea/evaluation/analyze_results.py +++ b/linajea/evaluation/analyze_results.py @@ -1,226 +1,75 @@ import logging -import os -import re -import pandas +import pandas as pd -from linajea.utils import (CandidateDatabase, - checkOrCreateDB) -from linajea.tracking import TrackingParameters +from linajea.utils import CandidateDatabase logger = logging.getLogger(__name__) -def get_sample_from_setup(setup): - sample_int = int(re.search(re.compile(r"\d*"), setup).group()) - if sample_int < 100: - return '140521' - elif sample_int < 200: - return '160328' - elif sample_int < 300: - return '120828' - else: - raise ValueError("Setup number must be < 300 to infer sample") - - -def get_result( - db_name, - tracking_parameters, - db_host, - frames=None, - sample=None, - iteration='400000'): - ''' Get the scores, statistics, and parameters for given - setup, region, and parameters. - Returns a dictionary containing the keys and values of the score - object. - - tracking_parameters can be a dict or a TrackingParameters object''' - candidate_db = CandidateDatabase(db_name, db_host, 'r') - if isinstance(tracking_parameters, dict): - tracking_parameters = TrackingParameters(**tracking_parameters) - parameters_id = candidate_db.get_parameters_id( - tracking_parameters, - fail_if_not_exists=True) - result = candidate_db.get_score(parameters_id, frames=frames) - return result - - -def get_greedy( - db_name, - db_host, - key="selected_greedy", - frames=None): - - candidate_db = CandidateDatabase(db_name, db_host, 'r') - result = candidate_db.get_score(key, frames=frames) - if result is None: - logger.error("Greedy result for db %d is None", db_name) - return result - - -def get_tgmm_results( - db_name, - db_host, - frames=None): - candidate_db = CandidateDatabase(db_name, db_host, 'r') - results = candidate_db.get_scores(frames=frames) - if results is None or len(results) == 0: - return None - all_results = pandas.DataFrame(results) - return all_results - - -def get_best_tgmm_result( - db_name, - db_host, - frames=None, - score_columns=None, - score_weights=None): - if not score_columns: - score_columns = ['fn_edges', 'identity_switches', - 'fp_divisions', 'fn_divisions'] - if not score_weights: - score_weights = [1.]*len(score_columns) - results_df = get_tgmm_results(db_name, db_host, frames=frames) - if results_df is None: - logger.warn("No TGMM results for db %s, and frames %s" - % (db_name, str(frames))) - return None - results_df['sum_errors'] = sum([results_df[col]*weight for col, weight - in zip(score_columns, score_weights)]) - results_df.sort_values('sum_errors', inplace=True) - best_result = results_df.iloc[0].to_dict() - best_result['setup'] = 'TGMM' - return best_result - - -def get_results( - db_name, - db_host, - sample=None, - iteration='400000', - frames=None, - filter_params=None): - ''' Gets the scores, statistics, and parameters for all - grid search configurations run for the given setup and region. - Returns a pandas dataframe with one row per configuration.''' - candidate_db = CandidateDatabase(db_name, db_host, 'r') - scores = candidate_db.get_scores(frames=frames, filters=filter_params) - dataframe = pandas.DataFrame(scores) - logger.debug("data types of dataframe columns: %s" - % str(dataframe.dtypes)) - if 'param_id' in dataframe: - dataframe['_id'] = dataframe['param_id'] - dataframe.set_index('param_id', inplace=True) - return dataframe - - -def get_best_result(db_name, db_host, - sample=None, - iteration='400000', - frames=None, - filter_params=None, - score_columns=None, - score_weights=None): - ''' Gets the best result for the given setup and region according to - the sum of errors in score_columns, with optional weighting. - - Returns a dictionary''' - if not score_columns: - score_columns = ['fn_edges', 'identity_switches', - 'fp_divisions', 'fn_divisions'] - if not score_weights: - score_weights = [1.]*len(score_columns) - results_df = get_results(db_name, db_host, - frames=frames, - sample=sample, iteration=iteration, - filter_params=filter_params) - results_df['sum_errors'] = sum([results_df[col]*weight for col, weight - in zip(score_columns, score_weights)]) - results_df.sort_values('sum_errors', inplace=True) - best_result = results_df.iloc[0].to_dict() - for key, value in best_result.items(): - try: - best_result[key] = value.item() - except AttributeError: - pass - return best_result - - -def get_best_result_per_setup(db_names, db_host, - frames=None, sample=None, iteration='400000', - filter_params=None, - score_columns=None, score_weights=None): - ''' Returns the best result for each db in db_names - according to the sum of errors in score_columns, with optional weighting, - sorted from best to worst (lowest to highest sum errors)''' - best_results = [] - for db_name in db_names: - best = get_best_result(db_name, db_host, - frames=frames, - sample=sample, iteration=iteration, - filter_params=filter_params, - score_columns=score_columns, - score_weights=score_weights) - best_results.append(best) - - best_df = pandas.DataFrame(best_results) - best_df.sort_values('sum_errors', inplace=True) - return best_df - - def get_results_sorted(config, filter_params=None, score_columns=None, score_weights=None, sort_by="sum_errors"): - if not score_columns: - score_columns = ['fn_edges', 'identity_switches', - 'fp_divisions', 'fn_divisions'] - if not score_weights: - score_weights = [1.]*len(score_columns) - + """Get sorted results based on config + + Args + ---- + config: TrackingConfig + Config object used to determine database name and host + and evaluation parameters + filter_params: dict + Has to be a valid mongodb query, used to filter results + score_columns: list of str + Which columns should count towards the sum of errors + score_weights: list of float + Option to use non-uniform weights per type of error + sort_by: str + Sort by which column/type of error (by default: sum) + + Returns + ------- + pandas.DataFrame + Sorted results stored in pandas.DataFrame object + """ db_name = config.inference_data.data_source.db_name - logger.info("checking db: %s", db_name) - - candidate_db = CandidateDatabase(db_name, config.general.db_host, 'r') - scores = candidate_db.get_scores(filters=filter_params, - eval_params=config.evaluate.parameters) - - if len(scores) == 0: - raise RuntimeError("no scores found!") - - results_df = pandas.DataFrame(scores) - logger.debug("data types of results_df dataframe columns: %s" - % str(results_df.dtypes)) - if 'param_id' in results_df: - results_df['_id'] = results_df['param_id'] - results_df.set_index('param_id', inplace=True) - - results_df['sum_errors'] = sum([results_df[col]*weight for col, weight - in zip(score_columns, score_weights)]) - results_df['sum_divs'] = sum( - [results_df[col]*weight for col, weight - in zip(score_columns[-2:], score_weights[-2:])]) - results_df = results_df.astype({"sum_errors": int, "sum_divs": int}) - ascending = True - if sort_by == "matched_edges": - ascending = False - results_df.sort_values(sort_by, ascending=ascending, inplace=True) - return results_df - - -def get_best_result_with_config(config, - filter_params=None, - score_columns=None, - score_weights=None): - ''' Gets the best result for the given setup and region according to - the sum of errors in score_columns, with optional weighting. - - Returns a dictionary''' - + return get_results_sorted_db(db_name, + config.general.db_host, + filter_params=filter_params, + eval_params=config.evaluate.parameters, + score_columns=score_columns, + score_weights=score_weights, + sort_by=sort_by) + + +def get_best_result_config(config, + filter_params=None, + score_columns=None, + score_weights=None): + """Get best result based on config + + Args + ---- + config: TrackingConfig + Config object used to determine database name and host + and evaluation parameters + filter_params: dict + Has to be a valid mongodb query, used to filter results + score_columns: list of str + Which columns should count towards the sum of errors + score_weights: list of float + Option to use non-uniform weights per type of error + sort_by: str + Sort by which column/type of error (by default: sum) + + Returns + ------- + dict + Get best result stored in dict + Includes, parameters used, parameter id and scores/errors + """ results_df = get_results_sorted(config, filter_params=filter_params, score_columns=score_columns, @@ -237,26 +86,50 @@ def get_best_result_with_config(config, def get_results_sorted_db(db_name, db_host, filter_params=None, + eval_params=None, score_columns=None, score_weights=None, sort_by="sum_errors"): + """Get sorted results from given database + + Args + ---- + db_name: str + Which database to use + db_host: str + Which database connection/host to use + filter_params: dict + Has to be a valid mongodb query, used to filter results + eval_params: EvaluateParametersConfig + Evaluation parameters config object, used to filter results + score_columns: list of str + Which columns should count towards the sum of errors + score_weights: list of float + Option to use non-uniform weights per type of error + sort_by: str + Sort by which column/type of error (by default: sum) + + Returns + ------- + pandas.DataFrame + Sorted results stored in pandas.DataFrame object + """ if not score_columns: score_columns = ['fn_edges', 'identity_switches', 'fp_divisions', 'fn_divisions'] if not score_weights: score_weights = [1.]*len(score_columns) - logger.info("checking db: %s", db_name) - + logger.info("Getting results in db: %s", db_name) candidate_db = CandidateDatabase(db_name, db_host, 'r') - scores = candidate_db.get_scores(filters=filter_params) + scores = candidate_db.get_scores(filters=filter_params, + eval_params=eval_params) + if len(scores) == 0: raise RuntimeError("no scores found!") - results_df = pandas.DataFrame(scores) - logger.debug("data types of results_df dataframe columns: %s" - % str(results_df.dtypes)) + results_df = pd.DataFrame(scores) if 'param_id' in results_df: results_df['_id'] = results_df['param_id'] results_df.set_index('param_id', inplace=True) @@ -277,10 +150,19 @@ def get_results_sorted_db(db_name, def get_result_id( config, parameters_id): - ''' Get the scores, statistics, and parameters for given - config and parameters_id. - Returns a dictionary containing the keys and values of the score - object. + ''' Get the scores, statistics, and parameters for given args + + Args + ---- + config: TrackingConfig + Configuration object, used to select database + parameters_id: int + Parameter ID, used to select solution within database + + Returns + ------- + dict + a dictionary containing the keys and values of the score object. ''' db_name = config.inference_data.data_source.db_name candidate_db = CandidateDatabase(db_name, config.general.db_host, 'r') @@ -293,10 +175,19 @@ def get_result_id( def get_result_params( config, parameters): - ''' Get the scores and statistics for a given config and set of - parameters. - Returns a dictionary containing the keys and values of the score - object. + ''' Get the scores, statistics, and parameters for given args + + Args + ---- + config: TrackingConfig + Configuration object, used to select database + parameters: int + Set of parameters, used to select solution within database + + Returns + ------- + dict + a dictionary containing the keys and values of the score object. ''' db_name = config.inference_data.data_source.db_name candidate_db = CandidateDatabase(db_name, config.general.db_host, 'r') diff --git a/linajea/evaluation/evaluate.py b/linajea/evaluation/evaluate.py index 08bb515..7a81119 100644 --- a/linajea/evaluation/evaluate.py +++ b/linajea/evaluation/evaluate.py @@ -1,6 +1,13 @@ +"""Compares two graphs and evaluates quality of matching + +Typical use case: +Match ground truth graph to reconstructed graph and evaluate result +""" +import logging + from .match import match_edges from .evaluator import Evaluator -import logging + logger = logging.getLogger(__name__) @@ -10,14 +17,45 @@ def evaluate( rec_track_graph, matching_threshold, sparse, - validation_score=False, - window_size=50, - ignore_one_off_div_errors=False, - fn_div_count_unconnected_parent=True): - ''' Performs both matching and evaluation on the given + validation_score, + window_size, + ignore_one_off_div_errors, + fn_div_count_unconnected_parent): + """Performs both matching and evaluation on the given gt and reconstructed tracks, and returns a Report with the results. - ''' + + Args + ---- + gt_track_graph: linajea.tracking.TrackGraph + Graph containing the ground truth annotations + rec_track_graph: linajea.tracking.TrackGraph + Reconstructed graph + matching_threshold: int + How far can a GT annotation and a predicted object be apart but + still be matched to each other. + sparse: bool + Is the ground truth sparse (not every instance is annotated) + validation_score: bool + Should the validation score be computed (additional metric) + window_size: int + What is the maximum window size for which the fraction of + error-free tracklets should be computed? + ignore_one_off_div_errors: bool + Division annotations are often slightly imprecise. Due to the + limited temporal resolution the exact moment a division happens + cannnot always be determined accuratly. If the predicted division + happens 1 frame before or after an annotated one, does not count + it as an error. + fn_div_count_unconnected_parent: bool + If the parent of the mother cell of a division is missing, should + this count as a division error (aside from the already counted FN + edge error) + Returns + ------- + Report + Report object containing detailed result + """ logger.info("Checking validity of reconstruction") Evaluator.check_track_validity(rec_track_graph) logger.info("Matching GT edges to REC edges...") diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index 7d19d1a..61df411 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -1,14 +1,12 @@ +"""Main evaluation function + +Loads graphs and evaluates +""" import logging import os -import sys import time -import networkx as nx -import numpy as np -import scipy.spatial - import daisy -import funlib.math import linajea.tracking import linajea.utils @@ -19,6 +17,26 @@ def evaluate_setup(linajea_config): + """Evaluates a given setup + + Determines parameters and database to use based on provided configuration. + Checks if solution has already been computed. + Loads graphs (ground truth and reconstructed) from databases. + Calls evaluate on graphs (compute matching and evaluation metrics). + Writes results to database. + Returns results. + + Args + ---- + linajea_config: TrackingConfig + Tracking configuration object, determines everything it needs + from config or uses defaults. + + Returns + ------- + Report + Report object containing all computed metrics and statistics + """ assert len(linajea_config.solve.parameters) == 1, \ "can only handle single parameter set" @@ -75,22 +93,11 @@ def evaluate_setup(linajea_config): subgraph.number_of_edges(), time.time() - start_time)) - if linajea_config.general.two_frame_edges: - subgraph = split_two_frame_edges(linajea_config, subgraph, evaluate_roi) - if subgraph.number_of_edges() == 0: logger.warn("No selected edges for parameters_id %d. Skipping" % parameters_id) return False - if linajea_config.evaluate.parameters.filter_polar_bodies or \ - linajea_config.evaluate.parameters.filter_polar_bodies_key: - subgraph = filter_polar_bodies(linajea_config, subgraph, - edges_db, evaluate_roi) - - subgraph = maybe_filter_short_tracklets(linajea_config, subgraph, - evaluate_roi) - track_graph = linajea.tracking.TrackGraph( subgraph, frame_key='t', roi=subgraph.roi) @@ -108,12 +115,6 @@ def evaluate_setup(linajea_config): gt_subgraph.number_of_edges(), time.time() - start_time)) - if linajea_config.inference_data.data_source.gt_db_name_polar is not None and \ - not linajea_config.evaluate.parameters.filter_polar_bodies and \ - not linajea_config.evaluate.parameters.filter_polar_bodies_key: - gt_subgraph = add_gt_polar_bodies(linajea_config, gt_subgraph, - db_host, evaluate_roi) - gt_track_graph = linajea.tracking.TrackGraph( gt_subgraph, frame_key='t', roi=gt_subgraph.roi) @@ -129,12 +130,12 @@ def evaluate_setup(linajea_config): report = evaluate( gt_track_graph, track_graph, - matching_threshold=matching_threshold, - sparse=linajea_config.general.sparse, - validation_score=validation_score, - window_size=window_size, - ignore_one_off_div_errors=ignore_one_off_div_errors, - fn_div_count_unconnected_parent=fn_div_count_unconnected_parent) + matching_threshold, + linajea_config.general.sparse, + validation_score, + window_size, + ignore_one_off_div_errors, + fn_div_count_unconnected_parent) logger.info("Done evaluating results for %d. Saving results to mongo." % parameters_id) @@ -142,169 +143,3 @@ def evaluate_setup(linajea_config): results_db.write_score(parameters_id, report, eval_params=linajea_config.evaluate.parameters) return report - - -def split_two_frame_edges(linajea_config, subgraph, evaluate_roi): - voxel_size = daisy.Coordinate(linajea_config.inference_data.data_source.voxel_size) - cells_by_frame = {} - for cell in subgraph.nodes: - cell = subgraph.nodes[cell] - t = cell['t'] - if t not in cells_by_frame: - cells_by_frame[t] = [] - cell_pos = np.array([cell['z'], cell['y'], cell['x']]) - cells_by_frame[t].append(cell_pos) - kd_trees_by_frame = {} - for t, cells in cells_by_frame.items(): - kd_trees_by_frame[t] = scipy.spatial.cKDTree(cells) - edges = list(subgraph.edges) - for u_id, v_id in edges: - u = subgraph.nodes[u_id] - v = subgraph.nodes[v_id] - frame_diff = abs(u['t']-v['t']) - if frame_diff == 1: - continue - elif frame_diff == 0: - raise RuntimeError("invalid edges? no diff in t %s %s", u, v) - elif frame_diff == 2: - u_pos = np.array([u['z'], u['y'], u['x']]) - v_pos = np.array([v['z'], v['y'], v['x']]) - w = {} - for k in u.keys(): - if k not in v.keys(): - continue - if "probable_gt" in k: - continue - u_v = u[k] - v_v = v[k] - - if "parent_vector" in k: - u_v = np.array(u_v) - v_v = np.array(v_v) - w[k] = (u_v + v_v)/2 - if "parent_vector" in k: - w[k] = list(w[k]) - w['t'] = u['t'] - 1 - w_id = int(funlib.math.cantor_number( - [i1/i2+i3 - for i1,i2,i3 in zip(evaluate_roi.get_begin(), - voxel_size, - [w['t'], w['z'], w['y'], w['x']])])) - d, i = kd_trees_by_frame[w['t']].query( - np.array([w['z'], w['y'], w['x']])) - print("inserted cell from two-frame-edge {} {} -> {}".format( - (u_id, u['t'], u_pos), (v_id, v['t'], v_pos), (w_id, [w['t'], w['z'], w['y'], w['x']]))) - subgraph.remove_edge(u_id, v_id) - if d <= 7: - print("inserted cell already exists {}".format(cells_by_frame[w['t']][i])) - else: - subgraph.add_node(w_id, **w) - subgraph.add_edge(u_id, w_id) - subgraph.add_edge(w_id, v_id) - else: - raise RuntimeError("invalid edges? diff of %s in t %s %s", frame_diff, u, v) - - logger.info("After splitting two_frame edges: %d cells and %d edges" - % (subgraph.number_of_nodes(), - subgraph.number_of_edges())) - - return subgraph - - -def filter_polar_bodies(linajea_config, subgraph, edges_db, evaluate_roi): - logger.debug("%s %s", - linajea_config.evaluate.parameters.filter_polar_bodies, - linajea_config.evaluate.parameters.filter_polar_bodies_key) - if not linajea_config.evaluate.parameters.filter_polar_bodies and \ - linajea_config.evaluate.parameters.filter_polar_bodies_key is not None: - pb_key = linajea_config.evaluate.parameters.filter_polar_bodies_key - else: - pb_key = linajea_config.solve.parameters[0].cell_cycle_key + "polar" - - # temp. remove edges from mother to daughter cells to split into chains - tmp_subgraph = edges_db.get_selected_graph(evaluate_roi) - for node in list(tmp_subgraph.nodes()): - if tmp_subgraph.degree(node) > 2: - es = list(tmp_subgraph.predecessors(node)) - tmp_subgraph.remove_edge(es[0], node) - tmp_subgraph.remove_edge(es[1], node) - rec_graph = linajea.tracking.TrackGraph( - tmp_subgraph, frame_key='t', roi=tmp_subgraph.roi) - - # for each chain - for track in rec_graph.get_tracks(): - cnt_nodes = 0 - cnt_polar = 0 - cnt_polar_uninterrupted = [[]] - nodes = [] - for node_id, node in track.nodes(data=True): - nodes.append((node['t'], node_id, node)) - - # check if > 50% are polar bodies - nodes = sorted(nodes) - for _, node_id, node in nodes: - cnt_nodes += 1 - try: - if node[pb_key] > 0.5: - cnt_polar += 1 - cnt_polar_uninterrupted[-1].append(node_id) - else: - cnt_polar_uninterrupted.append([]) - except KeyError: - pass - - # then remove - if cnt_polar/cnt_nodes > 0.5: - subgraph.remove_nodes_from(track.nodes()) - logger.info("removing %s potential polar nodes", - len(track.nodes())) - # else: - # for ch in cnt_polar_uninterrupted: - # if len(ch) > 5: - # subgraph.remove_nodes_from(ch) - # logger.info("removing %s potential polar nodes (2)", len(ch)) - - return subgraph - -def maybe_filter_short_tracklets(linajea_config, subgraph, evaluate_roi): - track_graph_tmp = linajea.tracking.TrackGraph( - subgraph, frame_key='t', roi=subgraph.roi) - last_frame = evaluate_roi.get_end()[0]-1 - for track in track_graph_tmp.get_tracks(): - min_t = 9999 - max_t = -1 - for node_id, node in track.nodes(data=True): - if node['t'] < min_t: - min_t = node['t'] - if node['t'] > max_t: - max_t = node['t'] - - logger.debug("track begin: {}, track end: {}, track len: {}".format( - min_t, max_t, len(track.nodes()))) - - if len(track.nodes()) < linajea_config.evaluate.parameters.filter_short_tracklets_len \ - and max_t != last_frame: - logger.info("removing %s nodes (very short tracks < %d)", - len(track.nodes()), - linajea_config.evaluate.filter_short_tracklets_len) - subgraph.remove_nodes_from(track.nodes()) - - return subgraph - - -def add_gt_polar_bodies(linajea_config, gt_subgraph, db_host, evaluate_roi): - logger.info("polar bodies are not filtered, adding polar body GT..") - gt_db_polar = linajea.utils.CandidateDatabase( - linajea_config.inference_data.data_source.gt_db_name_polar, db_host) - gt_polar_subgraph = gt_db_polar[evaluate_roi] - gt_mx_id = max(gt_subgraph.nodes()) + 1 - mapping = {n: n+gt_mx_id for n in gt_polar_subgraph.nodes()} - gt_polar_subgraph = nx.relabel_nodes(gt_polar_subgraph, mapping, - copy=False) - gt_subgraph.update(gt_polar_subgraph) - - logger.info("Read %d cells and %d edges (after adding polar bodies)" - % (gt_subgraph.number_of_nodes(), - gt_subgraph.number_of_edges())) - - return gt_subgraph diff --git a/linajea/evaluation/evaluator.py b/linajea/evaluation/evaluator.py index 8e6cb0e..d0d31e1 100644 --- a/linajea/evaluation/evaluator.py +++ b/linajea/evaluation/evaluator.py @@ -1,3 +1,5 @@ +"""Defines evaluator object +""" from copy import deepcopy import itertools import logging @@ -5,13 +7,14 @@ import networkx as nx from .report import Report from .validation_metric import validation_score -from .analyze_candidates import get_node_recall, get_edge_recall logger = logging.getLogger(__name__) class Evaluator: ''' A class for evaluating linajea results after matching. + + Takes two graphs and precomputed matched edges. Creates a report with statistics, error counts, and locations of errors. @@ -43,15 +46,13 @@ def __init__( rec_track_graph, edge_matches, unselected_potential_matches, - sparse=True, - validation_score=False, - window_size=50, - ignore_one_off_div_errors=False, - fn_div_count_unconnected_parent=True + sparse, + validation_score, + window_size, + ignore_one_off_div_errors, + fn_div_count_unconnected_parent ): self.report = Report() - self.report.fn_div_count_unconnected_parent = \ - fn_div_count_unconnected_parent self.gt_track_graph = gt_track_graph self.rec_track_graph = rec_track_graph @@ -61,13 +62,14 @@ def __init__( self.validation_score = validation_score self.window_size = window_size self.ignore_one_off_div_errors = ignore_one_off_div_errors + self.fn_div_count_unconnected_parent = fn_div_count_unconnected_parent # get tracks self.gt_tracks = gt_track_graph.get_tracks() self.rec_tracks = rec_track_graph.get_tracks() logger.debug("Found %d gt tracks and %d rec tracks" % (len(self.gt_tracks), len(self.rec_tracks))) - self.matched_track_ids = self.__get_track_matches() + self.matched_track_ids = self._get_track_matches() # get track statistics rec_matched_tracks = set() @@ -113,14 +115,7 @@ def evaluate(self): self.get_validation_score() if self.ignore_one_off_div_errors: self.get_div_topology_stats() - self.get_error_free_tracks() - - num_matches, num_gt_nodes = get_node_recall( - self.rec_track_graph, self.gt_track_graph, 15) - self.report.node_recall = num_matches / num_gt_nodes - num_matches, num_gt_edges = get_edge_recall( - self.rec_track_graph, self.gt_track_graph, 15, 35) - self.report.edge_recall = num_matches / num_gt_edges + return self.report def get_fp_edges(self): @@ -324,6 +319,7 @@ def get_fn_divisions(self): # FN - No connections logger.debug("FN - no connections") self.fn_div_no_connection_nodes.append(gt_parent) + self.gt_track_graph.nodes[gt_parent]['FN_D'] = True continue prev_edge = prev_edges[0] prev_edge_match = self.gt_edges_to_rec_edges.get(prev_edge) @@ -333,10 +329,12 @@ def get_fn_divisions(self): is_tp_div = False if self.__div_match_node_equality(c1_t, c2_t): - # TP - logger.debug("TP div") - self.tp_div_nodes.append(gt_parent) - is_tp_div = True + if self.__div_match_node_equality(c1_t, prev_s) or \ + not self.fn_div_count_unconnected_parent: + # TP + logger.debug("TP div") + self.tp_div_nodes.append(gt_parent) + is_tp_div = True if not self.__div_match_node_equality(c1_t, prev_s): # FN - Unconnected parent logger.debug("FN div - unconnected parent") @@ -347,10 +345,12 @@ def get_fn_divisions(self): # FN - one unconnected child logger.debug("FN div - one unconnected child") self.fn_div_unconnected_child_nodes.append(gt_parent) + self.gt_track_graph.nodes[gt_parent]['FN_D'] = True else: # FN - no connections logger.debug("FN div - no connections") self.fn_div_no_connection_nodes.append(gt_parent) + self.gt_track_graph.nodes[gt_parent]['FN_D'] = True if not is_tp_div: node = [self.gt_track_graph.nodes[gt_parent]['t'], @@ -362,7 +362,8 @@ def get_fn_divisions(self): self.report.set_fn_divisions(self.fn_div_no_connection_nodes, self.fn_div_unconnected_child_nodes, self.fn_div_unconnected_parent_nodes, - self.tp_div_nodes) + self.tp_div_nodes, + self.fn_div_count_unconnected_parent) def get_f_score(self): self.report.set_f_score() @@ -435,8 +436,9 @@ def get_perfect_segments(self, window_size): # check current node and next edge for current_node in current_nodes: if current_node != start_node: - if 'IS' in self.gt_track_graph.nodes[current_node] or\ - 'FP_D' in self.gt_track_graph.nodes[current_node]: + if ('IS' in self.gt_track_graph.nodes[current_node] or + 'FP_D' in self.gt_track_graph.nodes[current_node] or + 'FN_D' in self.gt_track_graph.nodes[current_node]): correct = False for next_edge in next_edges: if 'FN' in self.gt_track_graph.get_edge_data(*next_edge): @@ -472,7 +474,7 @@ def check_track_validity(track_graph): "Track has node %d with %d > 2 children" %\ (list(track_graph.nodes())[max_index], max(in_degrees)) - def __get_track_matches(self): + def _get_track_matches(self): self.edges_to_track_id_rec = {} self.edges_to_track_id_gt = {} track_ids_gt_to_rec = {} @@ -498,13 +500,19 @@ def get_validation_score(self): self.report.set_validation_score(vald_score) def get_div_topology_stats(self): + """Look for `isomorphic` division errors + + For each division error, check if there is one 1 frame earlier + or later. If yes, do not count it as an error. Only called if + self.ignore_one_off_div_errors is set. + """ self.iso_fn_div_nodes = [] for fn_div_node in itertools.chain( self.fn_div_no_connection_nodes, self.fn_div_unconnected_child_nodes, self.fn_div_unconnected_parent_nodes): - gt_tmp_grph, rec_tmp_grph = self.get_local_graphs( + gt_tmp_grph, rec_tmp_grph = self._get_local_graphs( fn_div_node, self.gt_track_graph, self.rec_track_graph) @@ -523,7 +531,7 @@ def get_div_topology_stats(self): self.iso_fp_div_nodes = [] for fp_div_node in self.fp_div_nodes: fp_div_node = int(fp_div_node) - rec_tmp_grph, gt_tmp_grph = self.get_local_graphs( + rec_tmp_grph, gt_tmp_grph = self._get_local_graphs( fp_div_node, self.rec_track_graph, self.gt_track_graph, rec_to_gt=True) if len(gt_tmp_grph.nodes()) == 0 or len(rec_tmp_grph) == 0: @@ -539,10 +547,11 @@ def get_div_topology_stats(self): else: logger.debug("not-isomorphic fp division: %d", fp_div_node) - self.report.set_iso_fn_divisions(self.iso_fn_div_nodes) + self.report.set_iso_fn_divisions(self.iso_fn_div_nodes, + self.fn_div_count_unconnected_parent) self.report.set_iso_fp_divisions(self.iso_fp_div_nodes) - def get_local_graphs(self, div_node, g1, g2, rec_to_gt=False): + def _get_local_graphs(self, div_node, g1, g2, rec_to_gt=False): g1_nodes = [] try: @@ -559,7 +568,7 @@ def get_local_graphs(self, div_node, g1, g2, rec_to_gt=False): for n2 in g1.predecessors(n1): g1_nodes.append(n2) except: - raise RuntimeError("Overlooked edge case in get_local_graph?") + raise RuntimeError("Overlooked edge case in _get_local_graph?") prev_edge = list(g1.prev_edges(div_node)) prev_edge = prev_edge[0] if len(prev_edge) > 0 else None @@ -607,53 +616,12 @@ def get_local_graphs(self, div_node, g1, g2, rec_to_gt=False): g2_tmp_grph = g2.subgraph(g2_nodes).to_undirected() if len(g1_tmp_grph.nodes()) != 0 and len(g2_tmp_grph.nodes()) != 0: - g1_tmp_grph = contract(g1_tmp_grph) - g2_tmp_grph = contract(g2_tmp_grph) + g1_tmp_grph = _contract(g1_tmp_grph) + g2_tmp_grph = _contract(g2_tmp_grph) return g1_tmp_grph, g2_tmp_grph - def get_error_free_tracks(self): - - roi = self.gt_track_graph.roi - start_frame = roi.get_offset()[0] - end_frame = start_frame + roi.get_shape()[0] - rec_nodes_last_frame = self.rec_track_graph.cells_by_frame(end_frame-1) - - cnt_rec_nodes_last_frame = len(rec_nodes_last_frame) - cnt_gt_nodes_last_frame = len(self.gt_track_graph.cells_by_frame( - end_frame-1)) - cnt_error_free_tracks = cnt_rec_nodes_last_frame - - nodes_prev_frame = rec_nodes_last_frame - logger.info("track range %s %s", end_frame-1, start_frame) - for i in range(end_frame-1, start_frame, -1): - nodes_curr_frame = [] - logger.debug("nodes in frame %s: %s ", i, len(nodes_prev_frame)) - for n in nodes_prev_frame: - prev_edges = list(self.rec_track_graph.prev_edges(n)) - if len(prev_edges) == 0: - logger.debug("no predecessor") - cnt_error_free_tracks -= 1 - continue - else: - assert len(prev_edges) == 1, \ - "node can only have single predecessor!" - if prev_edges[0] in self.rec_edges_to_gt_edges: - nodes_curr_frame.append(prev_edges[0][1]) - else: - logger.debug("predecessor not matched") - cnt_error_free_tracks -=1 - nodes_prev_frame = list(set(nodes_curr_frame)) - - logger.info("error free tracks: %s/%s %s", - cnt_error_free_tracks, cnt_rec_nodes_last_frame, - cnt_error_free_tracks/cnt_gt_nodes_last_frame) - self.report.set_error_free_tracks(cnt_error_free_tracks, - cnt_rec_nodes_last_frame, - cnt_gt_nodes_last_frame) - - -def contract(g): +def _contract(g): """ Contract chains of neighbouring vertices with degree 2 into one hypernode. Arguments: @@ -665,7 +633,10 @@ def contract(g): the contracted graph hypernode_to_nodes -- dict: int hypernode -> [v1, v2, ..., vn] dictionary mapping hypernodes to nodes - TODO: cite source + + Notes + ----- + Based on https://stackoverflow.com/a/52329262 """ # create subgraph of all nodes with degree 2 diff --git a/linajea/evaluation/match.py b/linajea/evaluation/match.py index 783bfac..5217355 100644 --- a/linajea/evaluation/match.py +++ b/linajea/evaluation/match.py @@ -1,9 +1,10 @@ -from __future__ import absolute_import -import pylp +"""Provides function to match edges in two graphs to each other +""" import logging import time import numpy as np +import pylp import scipy.sparse import scipy.spatial @@ -11,8 +12,10 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): - ''' - Arguments: + '''Perform matching of two graphs based on edges + + Args + ---- track_graph_x, track_graph_y (``linajea.TrackGraph``): Track graphs with the ground truth (x) and predicted (y) @@ -21,9 +24,12 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): If the nodes on both ends of an edge are within matching_threshold real world units, then they are allowed to be matched - Returns a list of edges in x, a list of edges in y, a list of edge - matches [(id_x, id_y), ...] referring to indexes in the returned lists, - and the number of edge false positives + Returns + ------- + list + A list of edges in x, a list of edges in y, a list of edge + matches [(id_x, id_y), ...] referring to indexes in the returned + lists, and the number of edge false positives ''' begin = min(track_graph_x.get_frames()[0], track_graph_x.get_frames()[0]) end = max(track_graph_x.get_frames()[1], track_graph_x.get_frames()[1]) + 1 @@ -101,7 +107,7 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): logger.debug("finding matches in frame %d" % t) node_pairs_two_frames = node_pairs_xy.copy() node_pairs_two_frames.update(node_pairs_xy_by_frame[t-1]) - edge_costs = get_edge_costs( + edge_costs = _get_edge_costs( edges_x, edges_y_by_source, node_pairs_two_frames) if edge_costs == {}: logger.info("No potential matches with source in frame %d" % t) @@ -109,8 +115,8 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): logger.debug("costs: %s" % edge_costs) y_edges_in_range = set(edge[1] for edge in edge_costs.keys()) logger.debug("Y edges in range: %s" % y_edges_in_range) - edge_matches_in_frame, _ = match(edge_costs, - 2*matching_threshold + 1) + edge_matches_in_frame, _ = _match(edge_costs, + 2*matching_threshold + 1) edge_matches.extend(edge_matches_in_frame) edge_fps_in_frame = len(y_edges_in_range) -\ len(edge_matches_in_frame) @@ -204,7 +210,7 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): return edges_x, edges_y, edge_matches, edge_fps -def get_edge_costs(edges_x, edges_y_by_source, node_pairs_xy): +def _get_edge_costs(edges_x, edges_y_by_source, node_pairs_xy): ''' Arguments: edges_x (list of int): @@ -244,7 +250,7 @@ def get_edge_costs(edges_x, edges_y_by_source, node_pairs_xy): return edge_costs -def match(costs, no_match_cost): +def _match(costs, no_match_cost): ''' Arguments: costs (``dict`` from ``tuple`` of ids to ``float``): diff --git a/linajea/evaluation/match_nodes.py b/linajea/evaluation/match_nodes.py index ca6bf5b..b3df4c6 100644 --- a/linajea/evaluation/match_nodes.py +++ b/linajea/evaluation/match_nodes.py @@ -1,9 +1,10 @@ -from __future__ import absolute_import -import pylp +"""Provides function to match nodes in two graphs to each other +""" import logging import time import numpy as np +import pylp import scipy.sparse import scipy.spatial @@ -11,8 +12,18 @@ def match_nodes(track_graph_x, track_graph_y, matching_threshold): - ''' - Arguments: + '''Perform matching of two graphs based on nodes + + Notes + ----- + Matches frame-wise the nodes in two graphs to each other. + Can be used to, e.g., compute the node recall of a tracking graph, + for a specific tracking solution but also for a candidate prediction + network on its own, independently of a specific tracking solution by + loading all predicted nodes and no edges. + + Args + ---- track_graph_x, track_graph_y (``linajea.TrackGraph``): Track graphs with the ground truth (x) and predicted (y) @@ -21,9 +32,12 @@ def match_nodes(track_graph_x, track_graph_y, matching_threshold): If the nodes are within matching_threshold real world units, then they are allowed to be matched - Returns a list of nodes in x, a list of nodes in y, a list of node - matches [(id_x, id_y), ...] referring to indexes in the returned lists, - and the number of node false positives + Returns + ------- + list + A list of nodes in x, a list of nodes in y, a list of node + matches [(id_x, id_y), ...] referring to indexes in the returned + lists, and the number of node false positives ''' begin = min(track_graph_x.get_frames()[0], track_graph_x.get_frames()[0]) end = max(track_graph_x.get_frames()[1], track_graph_x.get_frames()[1]) + 1 @@ -77,7 +91,7 @@ def match_nodes(track_graph_x, track_graph_y, matching_threshold): np.array(positions_x[i]) - np.array(positions_y[j])) node_costs[(node_x, node_y)] = distance - node_matches_in_frame, cost = match(node_costs, no_match_cost) + node_matches_in_frame, cost = _match(node_costs, no_match_cost) logger.info( "Done matching frame %d, found %d matches", t, len(node_matches_in_frame)) @@ -87,7 +101,7 @@ def match_nodes(track_graph_x, track_graph_y, matching_threshold): return node_matches -def match(costs, no_match_cost): +def _match(costs, no_match_cost): ''' Arguments: costs (``dict`` from ``tuple`` of ids to ``float``): diff --git a/linajea/evaluation/report.py b/linajea/evaluation/report.py index 5dbdc32..728bf97 100644 --- a/linajea/evaluation/report.py +++ b/linajea/evaluation/report.py @@ -1,3 +1,5 @@ +"""Provides a class containing all different metrics for a single solution +""" from copy import deepcopy import logging @@ -5,6 +7,16 @@ class Report: + """Object used to accumulate statistics for a solution + + Notes + ----- + A filled Report object contains all computed metrics and statistics + wrt to a single solution, such as number of gt/rec tracks, matched + tracks, how many edges/division, how many errors of which kind, + aggregated metrics such as precision, list of all edges/nodes + involved in errors. + """ def __init__(self): # STATISTICS self.gt_tracks = None @@ -49,8 +61,6 @@ def __init__(self): self.unconnected_parent_gt_nodes = None self.tp_div_gt_nodes = None - self.fn_div_count_unconnected_parent = False - def set_track_stats( self, gt_tracks, @@ -139,7 +149,8 @@ def set_fn_divisions( fn_divs_no_connections, fn_divs_unconnected_child, fn_divs_unconnected_parent, - tp_divs): + tp_divs + fn_div_count_unconnected_parent): ''' Args: fn_divs_... (list of int): @@ -155,7 +166,7 @@ def set_fn_divisions( self.fn_divs_unconnected_parent = len(fn_divs_unconnected_parent) self.fn_divisions = self.fn_divs_no_connections + \ self.fn_divs_unconnected_child - if self.fn_div_count_unconnected_parent: + if fn_div_count_unconnected_parent: self.fn_divisions += self.fn_divs_unconnected_parent self.no_connection_gt_nodes = [int(n) for n in fn_divs_no_connections] @@ -198,11 +209,17 @@ def set_aeftl_and_erl(self, aeftl, erl): def set_validation_score(self, validation_score): self.validation_score = validation_score - def set_iso_fn_divisions(self, iso_fn_div_nodes): + def set_iso_fn_divisions(self, iso_fn_div_nodes, + fn_div_count_unconnected_parent): + """If used, remove fn divisions that are off by only a single + frame from the list of false divisions and count them separately + as iso(morphic) fn divisions + + Adapt fn edges/fp edges stats accordingly""" self.iso_fn_division = len(iso_fn_div_nodes) fn_div_gt_nodes = (self.no_connection_gt_nodes + self.unconnected_child_gt_nodes) - if self.fn_div_count_unconnected_parent: + if fn_div_count_unconnected_parent: fn_div_gt_nodes += self.unconnected_parent_gt_nodes fn_div_gt_nodes = [f for f in fn_div_gt_nodes if f not in iso_fn_div_nodes] @@ -238,6 +255,11 @@ def set_iso_fn_divisions(self, iso_fn_div_nodes): logger.debug("fp edges after iso_fn_div: %d", self.fp_edges) def set_iso_fp_divisions(self, iso_fp_div_nodes): + """If used, remove fp divisions that are off by only a single + frame from the list of false divisions and count them separately + as iso(morphic) fp divisions + + Adapt fn edges/fp edges stats accordingly""" self.iso_fp_division = len(iso_fp_div_nodes) self.fp_div_rec_nodes = [f for f in self.fp_div_rec_nodes if f not in iso_fp_div_nodes] @@ -272,15 +294,25 @@ def set_iso_fp_divisions(self, iso_fp_div_nodes): logger.debug("fp edges after iso_fp_div: %d", self.fp_edges) logger.debug("fn edges after iso_fp_div: %d", self.fn_edges) - def set_error_free_tracks(self, cnt_error_free, cnt_total_rec, cnt_total_gt): - self.num_error_free_tracks = cnt_error_free - self.num_rec_cells_last_frame = cnt_total_rec - self.num_gt_cells_last_frame = cnt_total_gt def get_report(self): + """Long report + + Returns + ------- + dict + Dictionary containing all attributes of report + """ return self.__dict__ def get_short_report(self): + """Short report without lists + + Returns + ------- + dict + Dictionary with all lists of false edges/nodes removed + """ report = deepcopy(self.__dict__) # STATISTICS del report['fn_edge_list'] diff --git a/linajea/evaluation/validation_metric.py b/linajea/evaluation/validation_metric.py index c272596..9b2df9e 100644 --- a/linajea/evaluation/validation_metric.py +++ b/linajea/evaluation/validation_metric.py @@ -1,31 +1,40 @@ +"""Provides an additional track quality metric + +Topological errors incur a higher penalty +""" +import logging import math +import time + import numpy as np import networkx as nx -import logging -import time logger = logging.getLogger(__name__) def validation_score(gt_lineages, rec_lineages): - ''' Args: - - gt_lineages (networkx.DiGraph) - Ground truth cell lineages. Assumed to be sparse - - rec_lineages (networkx.DiGraph) - Reconstructed cell lineages - - Returns: - A float value that reflects the quality of a set of reconstructed - lineages. A lower score indicates higher quality. The score suffers a - high penalty for topological errors (FN edges, FN divisions, FP - divisions) and a lower penalty for having a large matching distance - between nodes in the GT and rec tracks. - ''' - gt_tracks = split_into_tracks(gt_lineages) - rec_tracks = split_into_tracks(rec_lineages) - rec_tracks_sorted_nodes = [sort_nodes(track) + """Computes a value that reflects the quality of a set of reconstructed + lineages. A lower score indicates higher quality. The score suffers a + high penalty for topological errors (FN edges, FN divisions, FP + divisions) and a lower penalty for having a large matching distance + between nodes in the GT and rec tracks. + + Args + ---- + gt_lineages (networkx.DiGraph) + Ground truth cell lineages. Assumed to be sparse + + rec_lineages (networkx.DiGraph) + Reconstructed cell lineages + + Returns + ------- + float + quality metric, lower is better + """ + gt_tracks = _split_into_tracks(gt_lineages) + rec_tracks = _split_into_tracks(rec_lineages) + rec_tracks_sorted_nodes = [_sort_nodes(track) for track in rec_tracks] # This is a naive approach where we compare all pairs of tracks. @@ -35,11 +44,11 @@ def validation_score(gt_lineages, rec_lineages): processed = 0 start_time = time.time() for gt_track in gt_tracks: - gt_nodes = sort_nodes(gt_track) + gt_nodes = _sort_nodes(gt_track) track_score = None for rec_nodes in rec_tracks_sorted_nodes: - s = track_distance(gt_nodes, rec_nodes, - current_min=track_score) + s = _track_distance(gt_nodes, rec_nodes, + current_min=track_score) if not s: # returned None because greater than current min continue if track_score is None or s < track_score: @@ -54,20 +63,20 @@ def validation_score(gt_lineages, rec_lineages): return total_score -def sort_nodes(track): +def _sort_nodes(track): # Sort nodes by frame (assumed 1 per frame) nodes = [d for n, d in track.nodes(data=True)] nodes = sorted(nodes, key=lambda n: n['t']) return nodes -def track_distance(track1, track2, current_min=None): +def _track_distance(track1, track2, current_min=None): if isinstance(track1, nx.DiGraph): - nodes1 = sort_nodes(track1) + nodes1 = _sort_nodes(track1) else: nodes1 = track1 if isinstance(track2, nx.DiGraph): - nodes2 = sort_nodes(track2) + nodes2 = _sort_nodes(track2) else: nodes2 = track2 @@ -96,12 +105,12 @@ def track_distance(track1, track2, current_min=None): t2 = nodes2[i2]['t'] if t1 == t2: logger.debug("Match in frame %d", t1) - node_dist = np.linalg.norm(node_loc(nodes1[i1]) - - node_loc(nodes2[i2])) + node_dist = np.linalg.norm(_node_loc(nodes1[i1]) - + _node_loc(nodes2[i2])) logger.debug("Euclidean distance: %f", node_dist) logger.debug("normalized distance: %f", - norm_distance(node_dist)) - dist += norm_distance(node_dist) + _norm_distance(node_dist)) + dist += _norm_distance(node_dist) i1 += 1 i2 += 1 else: @@ -122,11 +131,11 @@ def track_distance(track1, track2, current_min=None): return dist -def node_loc(data): +def _node_loc(data): return np.array([data['z'], data['y'], data['x']]) -def get_node_attr_range(graph, attr): +def _get_node_attr_range(graph, attr): ''' Returns the lowest value and one greater than the highest value''' low = None high = None @@ -141,7 +150,7 @@ def get_node_attr_range(graph, attr): return [low, high + 1] -def norm_distance(dist, inflect_point=50): +def _norm_distance(dist, inflect_point=50): ''' Normalize the distance to between 0 and 1 using the logistic function. The function will be adjusted so that the value at distance zero is 10^-4. Due to symmetry, the value at 2*inflect_point will be 1-10^-4. @@ -161,7 +170,7 @@ def norm_distance(dist, inflect_point=50): return 1. / (1 + math.pow(math.e, -1*slope*(dist - inflect_point))) -def split_into_tracks(lineages): +def _split_into_tracks(lineages): ''' Splits a lineage forest into a list of tracks, splitting at divisions Args: lineages (nx.DiGraph) @@ -175,15 +184,15 @@ def split_into_tracks(lineages): in_edges = list(lineages.in_edges(node)) min_id = 0 for edge in in_edges: - min_id = replace_target(edge, lineages, i=min_id) + min_id = _replace_target(edge, lineages, i=min_id) if len(lineages.out_edges(edge[1])) == 0: lineages.remove_node(edge[1]) - conn_components = get_connected_components(lineages) + conn_components = _get_connected_components(lineages) logger.info("Number of connected components: %d", len(conn_components)) return conn_components -def get_connected_components(graph): +def _get_connected_components(graph): subgraphs = [] node_set_generator = nx.weakly_connected_components(graph) for node_set in node_set_generator: @@ -195,11 +204,11 @@ def get_connected_components(graph): return subgraphs -def replace_target(edge, graph, i=0): +def _replace_target(edge, graph, i=0): old_id = edge[1] node_data = graph.nodes[old_id] edge_data = graph.edges[edge] - new_id = get_unused_node_id(graph, i) + new_id = _get_unused_node_id(graph, i) graph.add_node(new_id, **node_data) logger.debug("New node has data %s", graph.nodes[new_id]) graph.remove_edge(*edge) @@ -207,7 +216,7 @@ def replace_target(edge, graph, i=0): return new_id -def get_unused_node_id(graph, i=0): +def _get_unused_node_id(graph, i=0): while i in graph.nodes: i += 1 return i From 09c501eec5def77a1a8de19058364b46e64e41ea Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Wed, 29 Jun 2022 13:54:05 -0400 Subject: [PATCH 196/263] cleanup prediction, prune to main method --- linajea/prediction/predict.py | 57 +++++++++++++++++---- linajea/prediction/write_cells_from_zarr.py | 34 ++++++++---- 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/linajea/prediction/predict.py b/linajea/prediction/predict.py index 473593e..5794f94 100644 --- a/linajea/prediction/predict.py +++ b/linajea/prediction/predict.py @@ -1,3 +1,7 @@ +"""Script for a prediction worker process + +Predicts cells/nodes and writes them to database +""" import warnings warnings.filterwarnings("once", category=FutureWarning) @@ -28,17 +32,27 @@ def predict(config): + """Predict function used by a prediction worker process + + Sets up model and data and then repeatedly requests blocks to + predict using daisy until all blocks have been processed. + Args + ---- + config: TrackingConfig + Tracking configuration object, has to contain at least model, + prediction and data configuration + """ raw = gp.ArrayKey('RAW') cell_indicator = gp.ArrayKey('CELL_INDICATOR') maxima = gp.ArrayKey('MAXIMA') if not config.model.train_only_cell_indicator: - parent_vectors = gp.ArrayKey('PARENT_VECTORS') + movement_vectors = gp.ArrayKey('MOVEMENT_VECTORS') model = linajea.training.torch_model.UnetModelWrapper( config, config.inference_data.checkpoint) model.eval() - logger.info("Model: %s", model) + logger.debug("Model: %s", model) input_shape = config.model.predict_input_shape trial_run = model.forward(torch.zeros(input_shape, dtype=torch.float32)) @@ -54,7 +68,7 @@ def predict(config): chunk_request.add(cell_indicator, output_size) chunk_request.add(maxima, output_size) if not config.model.train_only_cell_indicator: - chunk_request.add(parent_vectors, output_size) + chunk_request.add(movement_vectors, output_size) sample = config.inference_data.data_source.datafile.filename if os.path.isfile(os.path.join(sample, "data_config.toml")): @@ -110,13 +124,13 @@ def predict(config): 1: maxima, } if not config.model.train_only_cell_indicator: - outputs[3] = parent_vectors + outputs[3] = movement_vectors dataset_names={ cell_indicator: 'volumes/cell_indicator', } if not config.model.train_only_cell_indicator: - dataset_names[parent_vectors] = 'volumes/parent_vectors' + dataset_names[movement_vectors] = 'volumes/movement_vectors' pipeline = ( source + @@ -138,8 +152,8 @@ def predict(config): gp.ZarrWrite( dataset_names=dataset_names, - output_filename=construct_zarr_filename(config, sample, - config.inference_data.checkpoint) + output_filename=construct_zarr_filename( + config, sample, config.inference_data.checkpoint) )) if not config.predict.no_db_access: cb.append(lambda b: write_done( @@ -157,7 +171,8 @@ def predict(config): WriteCells( maxima, cell_indicator, - parent_vectors if not config.model.train_only_cell_indicator else None, + movement_vectors if not config.model.train_only_cell_indicator + else None, score_threshold=config.inference_data.cell_score_threshold, db_host=config.general.db_host, db_name=config.inference_data.data_source.db_name, @@ -178,7 +193,7 @@ def predict(config): maxima: 'write_roi' } if not config.model.train_only_cell_indicator: - roi_map[parent_vectors] = 'write_roi' + roi_map[movement_vectors] = 'write_roi' pipeline = ( pipeline + @@ -196,12 +211,32 @@ def predict(config): def normalize(file_source, config, raw, data_config=None): + """Add data normalization node to pipeline. + + Should be identical to the one used during training + + Notes + ----- + Which normalization method should be used? + None/default: + [0,1] based on data type + minmax: + normalize such that lower bound is at 0 and upper bound at 1 + clipping is less strict, some data might be outside of range + percminmax: + use precomputed percentile values for minmax normalization; + precomputed values are stored in data_config file that has to + be supplied; set perc_min/max to tag to be used + mean/median + normalize such that mean/median is at 0 and 1 std/mad is at -+1 + set perc_min/max tags for clipping beforehand + """ if config.predict.normalization is None or \ config.predict.normalization.type == 'default': logger.info("default normalization") file_source = file_source + \ - gp.Normalize(raw, - factor=1.0/np.iinfo(data_config['stats']['dtype']).max) + gp.Normalize( + raw, factor=1.0/np.iinfo(data_config['stats']['dtype']).max) elif config.predict.normalization.type == 'minmax': mn = config.predict.normalization.norm_bounds[0] mx = config.predict.normalization.norm_bounds[1] diff --git a/linajea/prediction/write_cells_from_zarr.py b/linajea/prediction/write_cells_from_zarr.py index 586456e..3dcea85 100644 --- a/linajea/prediction/write_cells_from_zarr.py +++ b/linajea/prediction/write_cells_from_zarr.py @@ -1,3 +1,7 @@ +"""Script for a prediction worker process + +Writes cells/nodes from predicted zarr to database +""" import warnings warnings.filterwarnings("once", category=FutureWarning) @@ -5,7 +9,6 @@ import logging import os - import h5py import numpy as np @@ -22,11 +25,22 @@ def write_cells_from_zarr(config): - + """Function used by a prediction worker process + + Lazily loads already predicted array and then repeatedly requests + blocks to process using daisy until all blocks have been processed. + Locates maxima in each block and writes data to database. + + Args + ---- + config: TrackingConfig + Tracking configuration object, has to contain at least model + and data configuration + """ cell_indicator = gp.ArrayKey('CELL_INDICATOR') maxima = gp.ArrayKey('MAXIMA') if not config.model.train_only_cell_indicator: - parent_vectors = gp.ArrayKey('PARENT_VECTORS') + movement_vectors = gp.ArrayKey('MOVEMENT_VECTORS') voxel_size = gp.Coordinate(config.inference_data.data_source.voxel_size) output_size = gp.Coordinate(output_shape) * voxel_size @@ -35,7 +49,7 @@ def write_cells_from_zarr(config): chunk_request.add(cell_indicator, output_size) chunk_request.add(maxima, output_size) if not config.model.train_only_cell_indicator: - chunk_request.add(parent_vectors, output_size) + chunk_request.add(movement_vectors, output_size) sample = config.inference_data.data_source.datafile.filename if os.path.isfile(os.path.join(sample, "data_config.toml")): @@ -73,7 +87,7 @@ def write_cells_from_zarr(config): cell_indicator: 'volumes/cell_indicator', maxima: '/volumes/maxima'} if not config.model.train_only_cell_indicator: - datasets[parent_vectors] = 'volumes/parent_vectors' + datasets[movement_vectors] = 'volumes/movement_vectors' array_specs = { cell_indicator: gp.ArraySpec( @@ -83,7 +97,7 @@ def write_cells_from_zarr(config): interpolatable=False, voxel_size=voxel_size)} if not config.model.train_only_cell_indicator: - array_specs[parent_vectors] = gp.ArraySpec( + array_specs[movement_vectors] = gp.ArraySpec( interpolatable=True, voxel_size=voxel_size) @@ -97,7 +111,7 @@ def write_cells_from_zarr(config): maxima: 'write_roi' } if not config.model.train_only_cell_indicator: - roi_map[parent_vectors] = 'write_roi' + roi_map[movement_vectors] = 'write_roi' pipeline = ( source + @@ -106,14 +120,14 @@ def write_cells_from_zarr(config): if not config.model.train_only_cell_indicator: pipeline = (pipeline + - gp.Pad(parent_vectors, size=None)) + gp.Pad(movement_vectors, size=None)) pipeline = ( pipeline + WriteCells( maxima, cell_indicator, - parent_vectors if not config.model.train_only_cell_indicator else None, + movement_vectors if not config.model.train_only_cell_indicator else None, score_threshold=config.inference_data.cell_score_threshold, db_host=config.general.db_host, db_name=config.inference_data.data_source.db_name, @@ -145,4 +159,4 @@ def write_cells_from_zarr(config): args = parser.parse_args() config = TrackingConfig.from_file(args.config) - write_cells_sample(config) + write_cells_from_zarr(config) From 9f764b5b2012d7f127e81204216405cd0b9c9db4 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 1 Jul 2022 12:36:28 -0400 Subject: [PATCH 197/263] remove visualization scripts (for now) --- linajea/visualization/__init__.py | 0 linajea/visualization/ctc/__init__.py | 1 - linajea/visualization/ctc/write_ctc.py | 355 --- .../ImarisSpotsDataToTracklets.m | 157 - .../TrackletsToImarisSpotsData.m | 49 - .../demoBasicImarisCommunication.m | 25 - .../imaris/ImarisCodeBase/getSpots.m | 34 - .../ImarisCodeBase/openImarisConnection.m | 24 - .../ImarisCodeBase/openImarisConnectionAll.m | 15 - .../parseCATMAIDdbToImarisMultiSpots.m | 43 - .../parseCATMAIDdbToImarisSpots.m | 51 - .../imaris/ImarisCodeBase/setSpots.m | 37 - .../ImarisCodeBase/setSpotsAllProperties.m | 42 - .../ImarisCodeBase/updateBatchToDatabase.m | 152 - .../visualization/imaris/fixMamutParentIds.m | 25 - .../imaris/getNumericalAttribute.m | 8 - linajea/visualization/imaris/readCSV.m | 64 - linajea/visualization/imaris/readMamutXML.m | 62 - .../imaris/scriptExportMamut2imaris.m | 65 - .../imaris/splitTrackingMatrixIntoTracks.m | 24 - linajea/visualization/mamut/__init__.py | 25 - .../visualization/mamut/mamut_file_reader.py | 58 - .../mamut/mamut_matched_tracks_reader.py | 134 - .../visualization/mamut/mamut_mongo_reader.py | 154 - linajea/visualization/mamut/mamut_reader.py | 33 - linajea/visualization/mamut/mamut_writer.py | 206 -- .../mamut/mamut_xml_templates.py | 130 - .../visualization/mamut/test/140521_raw.xml | 2721 ----------------- .../mamut/test/test_mamut_writer.xml | 112 - .../mamut/test/test_mongo_reader.py | 65 - .../mamut/test/test_mongo_reader.xml | 112 - .../visualization/mamut/test/test_writer.py | 22 - linajea/visualization/spimagine/__init__.py | 7 - linajea/visualization/spimagine/get_tracks.py | 95 - .../visualization/spimagine/linajea_viewer.py | 354 --- 35 files changed, 5461 deletions(-) delete mode 100644 linajea/visualization/__init__.py delete mode 100644 linajea/visualization/ctc/__init__.py delete mode 100644 linajea/visualization/ctc/write_ctc.py delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/ImarisSpotsDataToTracklets.m delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/TrackletsToImarisSpotsData.m delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/demoBasicImarisCommunication.m delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/getSpots.m delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/openImarisConnection.m delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/openImarisConnectionAll.m delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/parseCATMAIDdbToImarisMultiSpots.m delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/parseCATMAIDdbToImarisSpots.m delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/setSpots.m delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/setSpotsAllProperties.m delete mode 100644 linajea/visualization/imaris/ImarisCodeBase/updateBatchToDatabase.m delete mode 100644 linajea/visualization/imaris/fixMamutParentIds.m delete mode 100644 linajea/visualization/imaris/getNumericalAttribute.m delete mode 100755 linajea/visualization/imaris/readCSV.m delete mode 100644 linajea/visualization/imaris/readMamutXML.m delete mode 100644 linajea/visualization/imaris/scriptExportMamut2imaris.m delete mode 100644 linajea/visualization/imaris/splitTrackingMatrixIntoTracks.m delete mode 100644 linajea/visualization/mamut/__init__.py delete mode 100644 linajea/visualization/mamut/mamut_file_reader.py delete mode 100644 linajea/visualization/mamut/mamut_matched_tracks_reader.py delete mode 100644 linajea/visualization/mamut/mamut_mongo_reader.py delete mode 100644 linajea/visualization/mamut/mamut_reader.py delete mode 100644 linajea/visualization/mamut/mamut_writer.py delete mode 100755 linajea/visualization/mamut/mamut_xml_templates.py delete mode 100644 linajea/visualization/mamut/test/140521_raw.xml delete mode 100644 linajea/visualization/mamut/test/test_mamut_writer.xml delete mode 100644 linajea/visualization/mamut/test/test_mongo_reader.py delete mode 100644 linajea/visualization/mamut/test/test_mongo_reader.xml delete mode 100644 linajea/visualization/mamut/test/test_writer.py delete mode 100644 linajea/visualization/spimagine/__init__.py delete mode 100644 linajea/visualization/spimagine/get_tracks.py delete mode 100644 linajea/visualization/spimagine/linajea_viewer.py diff --git a/linajea/visualization/__init__.py b/linajea/visualization/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/linajea/visualization/ctc/__init__.py b/linajea/visualization/ctc/__init__.py deleted file mode 100644 index b7e38c4..0000000 --- a/linajea/visualization/ctc/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .write_ctc import write_ctc diff --git a/linajea/visualization/ctc/write_ctc.py b/linajea/visualization/ctc/write_ctc.py deleted file mode 100644 index 007c807..0000000 --- a/linajea/visualization/ctc/write_ctc.py +++ /dev/null @@ -1,355 +0,0 @@ -import logging -import os - -import numpy as np -import raster_geometry as rg -import tifffile -import mahotas -import skimage.measure - -logger = logging.getLogger(__name__) - -logging.basicConfig(level=20) - - -def watershed(surface, markers, fg): - # compute watershed - ws = mahotas.cwatershed(1.0-surface, markers) - - # overlay fg and write - wsFG = ws * fg - logger.debug("watershed (foreground only): %s %s %f %f", - wsFG.shape, wsFG.dtype, wsFG.max(), - wsFG.min()) - wsFGUI = wsFG.astype(np.uint16) - - return wsFGUI, wsFG - - -def write_ctc(graph, start_frame, end_frame, shape, - out_dir, txt_fn, tif_fn, paint_sphere=False, - voxel_size=None, gt=False, surface=None, fg_threshold=0.5, - mask=None, radii=None): - os.makedirs(out_dir, exist_ok=True) - - logger.info("writing frames %s,%s (graph %s)", - start_frame, end_frame, graph.get_frames()) - # assert start_frame >= graph.get_frames()[0] - if end_frame is None: - end_frame = graph.get_frames()[1] + 1 - # else: - # assert end_frame <= graph.get_frames()[1] - track_cntr = 1 - - # dict, key: node, value: (trackID, parent trackID, current frame) - node_to_track = {} - curr_cells = set(graph.cells_by_frame(start_frame)) - for c in curr_cells: - node_to_track[c] = (track_cntr, 0, start_frame) - track_cntr += 1 - prev_cells = curr_cells - - # dict, key: frames, values: list of cells in frame k - cells_by_t_data = {} - - # for all frames except first one (handled before) - for f in range(start_frame+1, end_frame): - cells_by_t_data[f] = [] - curr_cells = set(graph.cells_by_frame(f)) - - for c in curr_cells: - node_to_track[c] = (track_cntr, 0, f) - track_cntr += 1 - # for all cells in previous frame - for c in prev_cells: - # get edges for cell - edges = list(graph.next_edges(c)) - assert len(edges) <= 2, "more than two children" - - # no division - if len(edges) == 1: - # get single edge - e = edges[0] - # assert source in curr_cells, target in prev_cells - assert e[0] in curr_cells, "cell missing {}".format(e) - assert e[1] in prev_cells, "cell missing {}".format(e) - - # add source to node_to_track - # get trackID from target node - # get parent trackID from target node - # set frame of node - node_to_track[e[0]] = ( - node_to_track[e[1]][0], - node_to_track[e[1]][1], - f) - # division - elif len(edges) == 2: - # get both edges - for e in edges: - # assert endpoints exist - assert e[0] in curr_cells, "cell missing" - assert e[1] in prev_cells, "cell missing" - # add source to node_to_track - # insert as new track/trackID - # get parent trackID from target node - node_to_track[e[0]] = ( - track_cntr, - node_to_track[e[1]][0], - f) - - # inc trackID - track_cntr += 1 - - if gt: - for e in edges: - st = e[1] - nd = e[0] - dataSt = graph.nodes(data=True)[st] - dataNd = graph.nodes(data=True)[nd] - cells_by_t_data[f].append(( - nd, - np.array([dataNd[d] - for d in ['z', 'y', 'x']]), - np.array([dataSt[d] - dataNd[d] - for d in ['z', 'y', 'x']]))) - prev_cells = set(graph.cells_by_frame(f)) - - tracks = {} - for c, v in node_to_track.items(): - if v[0] in tracks: - tracks[v[0]][1].append(v[2]) - else: - tracks[v[0]] = (v[1], [v[2]]) - - if not gt and not "unedited" in out_dir: - cells_by_t_data = { - t: [ - ( - cell, - np.array([data[d] for d in ['z', 'y', 'x']]), - np.array(data['parent_vector']) - ) - for cell, data in graph.nodes(data=True) - if 't' in data and data['t'] == t - ] - for t in range(start_frame, end_frame) - } - with open(os.path.join(out_dir, "parent_vectors.txt"), 'w') as of: - for t, cs in cells_by_t_data.items(): - for c in cs: - of.write("{} {} {} {} {} {} {} {} {}\n".format( - t, c[0], node_to_track[c[0]][0], - c[1][0], c[1][1], c[1][2], - c[2][0], c[2][1], c[2][2])) - - with open(os.path.join(out_dir, txt_fn), 'w') as of: - for t, v in tracks.items(): - logger.debug("{} {} {} {}".format( - t, min(v[1]), max(v[1]), v[0])) - of.write("{} {} {} {}\n".format( - t, min(v[1]), max(v[1]), v[0])) - - if paint_sphere: - spheres = {} - - if radii is not None: - for th, r in radii.items(): - sphere_shape = (max(3, r//voxel_size[1]+1), r, r) - zh = sphere_shape[0]//2 - yh = sphere_shape[1]//2 - xh = sphere_shape[2]//2 - sphere_rad = (sphere_shape[0]/2, - sphere_shape[1]/2, - sphere_shape[2]/2) - sphere = rg.ellipsoid(sphere_shape, sphere_rad) - spheres[th] = [sphere, zh, yh, xh] - else: - for f in range(start_frame, end_frame): - for c, v in node_to_track.items(): - if f != v[2]: - continue - rt = int(graph.nodes[c]['r']) - if rt in spheres: - continue - rz = max(5, rt*2//voxel_size[1]+1) - r = rt * 2 - 1 - if rz % 2 == 0: - rz -= 1 - sphere_shape = (rz, r, r) - zh = sphere_shape[0]//2 - yh = sphere_shape[1]//2 - xh = sphere_shape[2]//2 - sphere_rad = (sphere_shape[0]//2, - sphere_shape[1]//2, - sphere_shape[2]//2) - sphere = rg.ellipsoid(sphere_shape, sphere_rad) - logger.debug("%s %s %s %s %s %s %s", rz, r*2//voxel_size[1]+1, - graph.nodes[c]['r'], r, sphere.shape, - sphere_shape, [sphere.shape, zh, yh, xh]) - spheres[rt] = [sphere, zh, yh, xh] - - for f in range(start_frame, end_frame): - logger.info("Processing frame %d" % f) - arr = np.zeros(shape[1:], dtype=np.uint16) - if surface is not None: - fg = (surface[f] > fg_threshold).astype(np.uint8) - if mask is not None: - fg *= mask - - for c, v in node_to_track.items(): - if f != v[2]: - continue - t = graph.nodes[c]['t'] - z = int(graph.nodes[c]['z']/voxel_size[1]) - y = int(graph.nodes[c]['y']/voxel_size[2]) - x = int(graph.nodes[c]['x']/voxel_size[3]) - logger.debug("%s %s %s %s %s %s %s %s", - v[0], f, t, z, y, x, c, v) - if paint_sphere: - if radii is not None: - for th in sorted(spheres.keys()): - if t < int(th): - sphere, zh, yh, xh = spheres[th] - break - else: - r = int(graph.nodes[c]['r']) - sphere, zh, yh, xh = spheres[r] - try: - arr[(z-zh):(z+zh+1), - (y-yh):(y+yh+1), - (x-xh):(x+xh+1)] = sphere * v[0] - except ValueError as e: - logger.debug("%s", e) - logger.debug("%s %s %s %s %s %s %s", - z, zh, y, yh, x, xh, sphere.shape) - logger.debug("%s %s %s %s %s %s %s", - z-zh, z+zh+1, y-yh, y+yh+1, x-xh, x+xh+1, - sphere.shape) - sphereT = np.copy(sphere) - if z-zh < 0: - zz1 = 0 - sphereT = sphereT[(-(z-zh)):, ...] - logger.debug("%s", sphereT.shape) - else: - zz1 = z-zh - if z+zh+1 >= arr.shape[0]: - zz2 = arr.shape[0] - zt = arr.shape[0] - (z+zh+1) - sphereT = sphereT[:zt, ...] - logger.debug("%s", sphereT.shape) - else: - zz2 = z+zh+1 - - if y-yh < 0: - yy1 = 0 - sphereT = sphereT[:, (-(y - yh)):, ...] - logger.debug("%s", sphereT.shape) - else: - yy1 = y-yh - if y+yh+1 >= arr.shape[1]: - yy2 = arr.shape[1] - yt = arr.shape[1] - (y+yh+1) - sphereT = sphereT[:, :yt, ...] - logger.debug("%s", sphereT.shape) - else: - yy2 = y+yh+1 - - if x-xh < 0: - xx1 = 0 - sphereT = sphereT[..., (-(x-xh)):] - logger.debug("%s", sphereT.shape) - else: - xx1 = x-xh - if x+xh+1 >= arr.shape[2]: - xx2 = arr.shape[2] - xt = arr.shape[2] - (x+xh+1) - sphereT = sphereT[..., :xt] - logger.debug("%s", sphereT.shape) - else: - xx2 = x+xh+1 - - logger.debug("%s %s %s %s %s %s", - zz1, zz2, yy1, yy2, xx1, xx2) - arr[zz1:zz2, - yy1:yy2, - xx1:xx2] = sphereT * v[0] - # raise e - else: - if gt: - for zd in range(-1, 2): - for yd in range(-2, 3): - for xd in range(-2, 3): - arr[z+zd, y+yd, x+xd] = v[0] - else: - arr[z, y, x] = v[0] - if surface is not None: - radii = radii if radii is not None else {10000: 12} - for th in sorted(radii.keys()): - if f < th: - d = radii[th] - break - logger.debug("%s %s %s %s", - f, surface[f].shape, arr.shape, fg.shape) - arr1, arr2 = watershed(surface[f], arr, fg) - arr_tmp = np.zeros_like(arr) - tmp1 = np.argwhere(arr != 0) - for n in tmp1: - u = arr1[tuple(n)] - tmp = (arr1 == u).astype(np.uint32) - tmp = skimage.measure.label(tmp) - val = tmp[tuple(n)] - tmp = tmp == val - - vs = np.argwhere(tmp != 0) - - vss = np.copy(vs) - vss[:, 0] *= voxel_size[1] - n[0] *= voxel_size[1] - tmp2 = np.argwhere(np.linalg.norm(n-vss, axis=1) < d) - assert len(tmp2) > 0,\ - "no pixel found {} {} {} {}".format(f, d, n, val) - for v in tmp2: - arr_tmp[tuple(vs[v][0])] = u - arr = arr_tmp - - - if paint_sphere: - for c, v in node_to_track.items(): - if f != v[2]: - continue - t = graph.nodes[c]['t'] - z = int(graph.nodes[c]['z']/voxel_size[1]) - y = int(graph.nodes[c]['y']/voxel_size[2]) - x = int(graph.nodes[c]['x']/voxel_size[3]) - r = int(graph.nodes[c]['r']) - for zd in range(-1, 2): - for yd in range(-2, 3): - for xd in range(-2, 3): - arr[z+zd, y+yd, x+xd] = v[0] - if paint_sphere: - for c, v in node_to_track.items(): - if (v[0] == 30217): - t = graph.nodes[c]['t'] - z = int(graph.nodes[c]['z']/voxel_size[1])+1 - y = int(graph.nodes[c]['y']/voxel_size[2])+2 - x = int(graph.nodes[c]['x']/voxel_size[3])+2 - r = int(graph.nodes[c]['r']) - for zd in range(-1, 2): - for yd in range(-2, 3): - for xd in range(-2, 3): - arr[z+zd, y+yd, x+xd] = v[0] - if (v[0] == 33721): - t = graph.nodes[c]['t'] - z = int(graph.nodes[c]['z']/voxel_size[1])-1 - y = int(graph.nodes[c]['y']/voxel_size[2])-2 - x = int(graph.nodes[c]['x']/voxel_size[3])-2 - r = int(graph.nodes[c]['r']) - for zd in range(-1, 2): - for yd in range(-2, 3): - for xd in range(-2, 3): - arr[z+zd, y+yd, x+xd] = v[0] - - logger.info("Writing tiff tile for frame %d" % f) - tifffile.imwrite(os.path.join( - out_dir, tif_fn.format(f)), arr, - compress=3) diff --git a/linajea/visualization/imaris/ImarisCodeBase/ImarisSpotsDataToTracklets.m b/linajea/visualization/imaris/ImarisCodeBase/ImarisSpotsDataToTracklets.m deleted file mode 100644 index 8943915..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/ImarisSpotsDataToTracklets.m +++ /dev/null @@ -1,157 +0,0 @@ -%transforms Imaris spot format (vertices+pairwise edges) into ordered -%subtracts - -function [tracklets connTrack]=ImarisSpotsDataToTracklets(vert,tIdx,aEdges) - - - -%find candidates to start tracklets: basically nodes which do not appear -%twice in the edges list - -[p u]=hist(double(aEdges(:)),[min(double(aEdges(:))): max(double(aEdges(:)))]); -pos=find(p~=2); - -%we will store the starting point of each subtrack in seeds -seedsT=zeros(1000,1); -countT=0; -for kk=1:length(pos) - p_kk=p(pos(kk)); - u_kk=u(pos(kk)); - if(p_kk==1)%end or beginning of a track - %check if it is the beginning of the track (otherwise we need to - %disregard) - isBeginning=false; - pp=find(aEdges(:,1)==u_kk); - if(isempty(pp)) - pp=find(aEdges(:,2)==u_kk); - if(tIdx(aEdges(pp,1))>tIdx(aEdges(pp,2))) - isBeginning=true; - end - else - if(tIdx(aEdges(pp,1))countT) - seedsT(countT+1:end)=[]; -end - -%find each track starting from seed -tracklets=cell(countT,1); - - -auxT=zeros(500,5);%[t x y z id] -for kk=1:countT - - count=0; - queue=seedsT(kk); - - while(isempty(queue)==false) - parentId=queue(1); - queue(1)=[]; - if(sum(parentId==seedsT)>0 && count>0) - break;% we have reached another subtrack - end - - %add point to tracklet - count=count+1; - auxT(count,:)=[double(tIdx(parentId)) double(vert(parentId,:)) parentId]; - - %find daughter - pp=find(aEdges(:,1)==parentId); - for ii=1:length(pp) - if(tIdx(aEdges(pp(ii),1))countC) - connTrack(countC+1:end,:)=[]; -end - - - - - - - - - - - - - - - - - - - - - - diff --git a/linajea/visualization/imaris/ImarisCodeBase/TrackletsToImarisSpotsData.m b/linajea/visualization/imaris/ImarisCodeBase/TrackletsToImarisSpotsData.m deleted file mode 100644 index 52c1190..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/TrackletsToImarisSpotsData.m +++ /dev/null @@ -1,49 +0,0 @@ -function [vert,tIdx,aEdges]=TrackletsToImarisSpotsData(tracklets,connTrack) - - -numT=length(tracklets); -%calculate total number of spots -numSpots=0; -for kk=1:numT - numSpots=numSpots+size(tracklets{kk},1); -end - -%save vert, tIdx and edges -vert=zeros(numSpots,3); -tIdx=zeros(numSpots,1); -aEdges=zeros(2*numSpots,2); -numE=0; - -offset=ones(numT+1,1);%offset for each tracklet within vert. +1 to handle last tracklet smoothly in the code without if statements. -for kk=1:numT - N=size(tracklets{kk},1); - offset(kk+1)=offset(kk)+N; - - vert(offset(kk):offset(kk+1)-1,:)=tracklets{kk}(:,2:4); - tIdx(offset(kk):offset(kk+1)-1)=tracklets{kk}(:,1); - aEdges(numE+1:numE+N-1,:)=[[offset(kk):offset(kk+1)-2]' 1+[offset(kk):offset(kk+1)-2]']; - numE=numE+N-1; -end - -%add final edges between different tracklets -for kk=1:size(connTrack,1) - parent=connTrack(kk,1); - for ii=1:2 - daughter=connTrack(kk,1+ii); - if(daughter<=0) continue;end; - - numE=numE+1; - aEdges(numE,:)=sort([offset(parent+1)-1 offset(daughter)],'ascend'); - end -end - -if(size(aEdges,1)>numE) - aEdges(numE+1:end,:)=[]; -end - - - -%transform each array to its correct format for Imaris -vert=single(vert); -tIdx=int32(tIdx); -aEdges=int32(aEdges); \ No newline at end of file diff --git a/linajea/visualization/imaris/ImarisCodeBase/demoBasicImarisCommunication.m b/linajea/visualization/imaris/ImarisCodeBase/demoBasicImarisCommunication.m deleted file mode 100644 index 5226cc9..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/demoBasicImarisCommunication.m +++ /dev/null @@ -1,25 +0,0 @@ -%basic example on how to connect to running Imaris session and manipulate -%data -%function basicImarisCommunication() - -%--------------------------------------------------------- -%-----basic interface to setup the pipe betwen Matlab and Imaris---------- -% connect to Imaris interface: Imaris should be running!!! -javaaddpath C:\Program' Files'\Bitplane\Imaris' x64 9.5.0'\XT\Matlab\ImarisLib.jar -vImarisLib = ImarisLib; -vServer = vImarisLib.GetServer; - -if(vServer.GetNumberOfObjects~=1) - error 'Either Imaris is not running or you are running more than one instance. Not allowed in this context.' -end -vObjectId=vServer.GetObjectID(0); -vImarisApplication = vImarisLib.GetApplication(vObjectId); - -%------------------------------------------------------------------- - -%now I can run any command -aSurface = vImarisApplication.GetFactory.ToSurfaces(vImarisApplication.GetSurpassSelection); - aSurfaceIndex = 0; - aTimeIndex = aSurface.GetTimeIndex(aSurfaceIndex) - - \ No newline at end of file diff --git a/linajea/visualization/imaris/ImarisCodeBase/getSpots.m b/linajea/visualization/imaris/ImarisCodeBase/getSpots.m deleted file mode 100644 index c1bb003..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/getSpots.m +++ /dev/null @@ -1,34 +0,0 @@ -function [vImarisApplication vert tIdx radii aEdges trackId]=getSpots(offset,vImarisApplication) - -%offset=[259 190 49 0]%[X y Z t] offset in case we have cropped the volume - - -%connect to both Imaris sessions -if(isempty(vImarisApplication)) - [vImarisApplication]=openImarisConnection; -end - -aSpots = vImarisApplication.GetFactory.ToSpots(vImarisApplication.GetSurpassSelection); -vImarisDataSet = vImarisApplication.GetDataSet; -vDataMin = [vImarisDataSet.GetExtendMinX, vImarisDataSet.GetExtendMinY, vImarisDataSet.GetExtendMinZ]; -vDataMax = [vImarisDataSet.GetExtendMaxX, vImarisDataSet.GetExtendMaxY, vImarisDataSet.GetExtendMaxZ]; -vDataSize = [vImarisDataSet.GetSizeX, vImarisDataSet.GetSizeY, vImarisDataSet.GetSizeZ]; - - -auxSc=vDataSize./(vDataMax-vDataMin); - - -%get XYZ, time and radius -tIdx=aSpots.GetIndicesT(); -vert=aSpots.GetPositionsXYZ(); -radii=aSpots.GetRadii(); - -%get track edges and id -disp 'WARNING: we added already +1 to aEdges to set it as Matlab-indexing' -aEdges = aSpots.GetTrackEdges()+1;%to set it in Matlab coordinates instead of C-indexing -trackId=aSpots.GetTrackIds; - -%apply offset -tIdx=tIdx+offset(4); -vert=vert.*repmat(auxSc,[size(vert,1) 1]);%center og mass in returned in metric not in pixels -vert=vert+repmat(offset(1:3),[size(vert,1) 1]);%apply translation offset diff --git a/linajea/visualization/imaris/ImarisCodeBase/openImarisConnection.m b/linajea/visualization/imaris/ImarisCodeBase/openImarisConnection.m deleted file mode 100644 index 1514420..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/openImarisConnection.m +++ /dev/null @@ -1,24 +0,0 @@ -function vImarisApplication=openImarisConnection() -%--------------------------------------------------------- -%-----basic interface to setup the pipe betwen Matlab and Imaris---------- -% connect to Imaris interface: Imaris should be running!!! -p = 'C:\Program Files\Bitplane\Imaris x64 9.5.0\XT\Matlab\ImarisLib.jar'; -if ~ismember(p, javaclasspath) - javaaddpath(p) -end -vImarisLib = ImarisLib; -vServer = vImarisLib.GetServer; - -objN=0; - - if(vServer.GetNumberOfObjects>2) - error 'Either Imaris is not running or you are running more than one instance. Not allowed in this context.' - elseif(vServer.GetNumberOfObjects==2) - disp 'WARNING: using second Imaris object (most likely another user has also opened Imaris' - objN=1; - end - - -vObjectId=vServer.GetObjectID(objN); -vImarisApplication = vImarisLib.GetApplication(vObjectId); - \ No newline at end of file diff --git a/linajea/visualization/imaris/ImarisCodeBase/openImarisConnectionAll.m b/linajea/visualization/imaris/ImarisCodeBase/openImarisConnectionAll.m deleted file mode 100644 index 73cc076..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/openImarisConnectionAll.m +++ /dev/null @@ -1,15 +0,0 @@ -function [vImarisApplicationVec]=openImarisConnectionAll() -%--------------------------------------------------------- -%-----basic interface to setup the pipe betwen Matlab and Imaris---------- -% connect to Imaris interface: Imaris should be running!!! -javaaddpath C:\Program' Files'\Bitplane\Imaris' x64 7.4.0'\XT\Matlab\ImarisLib.jar -vImarisLib = ImarisLib; -vServer = vImarisLib.GetServer; - -N=vServer.GetNumberOfObjects; -vImarisApplicationVec=cell(N,1); - -for kk=1:N - vObjectId=vServer.GetObjectID(kk-1); - vImarisApplicationVec{kk} = vImarisLib.GetApplication(vObjectId); -end \ No newline at end of file diff --git a/linajea/visualization/imaris/ImarisCodeBase/parseCATMAIDdbToImarisMultiSpots.m b/linajea/visualization/imaris/ImarisCodeBase/parseCATMAIDdbToImarisMultiSpots.m deleted file mode 100644 index 21dbc7c..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/parseCATMAIDdbToImarisMultiSpots.m +++ /dev/null @@ -1,43 +0,0 @@ - -% INPUT: -% -% -%trackingM: array Nx 10 retrived from tracking information in CATMAID databse -%information from CATMAID database -%stackRes: array 3x1 indicating resolution (needed to parse xyz from CATMAID (in um) to pixels -%sourceVolumeDimensions: array 3x1 indicating dimensions of image stack. -%Use [] if you did not scale the volume in Imaris -%targetVolumeDimensions: array 3x1 to use "fake" imaris volume. Use [] if -%the volume at Imaris has the same dimensions as sourceVolumeDimensions - - -%trackingM matrix contains teh following columns -%id, type, x, y, z, radius, parent_id, time, confidence, skeleton_id -function [skeletonIdVec, spotIdxVec] = parseCATMAIDdbToImarisMultiSpots(trackingM, stackRes,sourceVolumeDimensions, targetVolumeDimensions) - -skeletonIdVec = unique( trackingM(:,10 ) ); - -spotIdxVec = zeros(length(skeletonIdVec),1); -for kk = 1: length(skeletonIdVec) - skeletonId = skeletonIdVec(kk); - trackingMaux = trackingM( trackingM(:,10) == skeletonId,: ); - - %create new spots entry in current Imaris scene - - [vImarisApplication]=openImarisConnection; - aSurpassScene = vImarisApplication.GetSurpassScene; - - aSpots = vImarisApplication.GetFactory.CreateSpots; - aSurpassScene.AddChild( aSpots, -1 );%add at the end - idx = aSurpassScene.GetNumberOfChildren; - spotIdxVec(kk) = idx; - %set current spot as selected - vImarisApplication.SetSurpassSelection(aSurpassScene.GetChild(idx-1));%0-indexing - %vImarisApplication.GetSurpassSelection.SetName(name); %uncomment this - %line if you want to define a name - - %update spots - parseCATMAIDdbToImarisSpots(trackingMaux, stackRes, sourceVolumeDimensions, targetVolumeDimensions); - - %disp(['Spot ' num2str(kk) ' to skeletonId ' num2str(skeletonId)]); -end diff --git a/linajea/visualization/imaris/ImarisCodeBase/parseCATMAIDdbToImarisSpots.m b/linajea/visualization/imaris/ImarisCodeBase/parseCATMAIDdbToImarisSpots.m deleted file mode 100644 index 3058cd1..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/parseCATMAIDdbToImarisSpots.m +++ /dev/null @@ -1,51 +0,0 @@ - -% INPUT: -% -% -%trackingM: array Nx 10 retrived from tracking information in CATMAID databse -%information from CATMAID database -%stackRes: array 3x1 indicating resolution (needed to parse xyz from CATMAID (in um) to pixels -%sourceVolumeDimensions: array 3x1 indicating dimensions of image stack. -%Use [] if you did not scale the volume in Imaris -%targetVolumeDimensions: array 3x1 to use "fake" imaris volume. Use [] if -%the volume at Imaris has the same dimensions as sourceVolumeDimensions - - -%trackingM matrix contains teh following columns -%id, type, x, y, z, radius, parent_id, time, confidence, skeleton_id -function parseCATMAIDdbToImarisSpots(trackingM, stackRes,sourceVolumeDimensions, targetVolumeDimensions) - -%parse vertices -disp 'calculating vertices ...' -tIdx = trackingM(:,8); -vert = trackingM(:,3:5); -for kk =1 :3 - vert(:,kk) = vert(:, kk ) / stackRes(:,kk); -end - -%scale vertices -if( isempty(sourceVolumeDimensions) == false ) - vert = single(vert); - for kk = 1:size(vert,2) - vert(:,kk) = vert(:,kk) * (targetVolumeDimensions(kk) / sourceVolumeDimensions(kk)); - end -end - - -%calculating edges -disp 'calculating edges ...' -pos = find( trackingM(:,7) >= 0);%all elements with an edge -nodeIdMap = containers.Map(trackingM(:,1),[1:size(trackingM,1)]); -aEdges = [pos pos];%to reserve memory -for kk = 1: length(pos)%container.Map needs cell array input to vectorize output - aEdges(kk,2) = nodeIdMap(trackingM(pos(kk),7)); -end - - -%update spots to Imaris -disp 'Updating spots in Imaris...' -tIdx=int32(tIdx); -vert=single(vert); -radii=ones(size(tIdx),'single'); -aEdges=int32(aEdges); -setSpots(vert,tIdx,radii,aEdges,[1 1 1],[0 0 0 0]);%we assume scale is [1 1 1] since results have been conducted in teh same dataset \ No newline at end of file diff --git a/linajea/visualization/imaris/ImarisCodeBase/setSpots.m b/linajea/visualization/imaris/ImarisCodeBase/setSpots.m deleted file mode 100644 index 768be7a..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/setSpots.m +++ /dev/null @@ -1,37 +0,0 @@ -%warning: aEdges should be in Matlab indexing; so 1 is the minimum value -function setSpots(vert,tIdx,radii,aEdges,scale,offset) - -%offset=[0 0 0 0]%[X y Z t] offset in case we have cropped the volume -%scale=[1 1 5] - -%connect to both Imaris sessions -[vImarisApplication]=openImarisConnection; - - -aSpots = vImarisApplication.GetFactory.ToSpots(vImarisApplication.GetSurpassSelection); -vImarisDataSet = vImarisApplication.GetDataSet; -vDataMin = [vImarisDataSet.GetExtendMinX, vImarisDataSet.GetExtendMinY, vImarisDataSet.GetExtendMinZ]; -vDataMax = [vImarisDataSet.GetExtendMaxX, vImarisDataSet.GetExtendMaxY, vImarisDataSet.GetExtendMaxZ]; -vDataSize = [vImarisDataSet.GetSizeX, vImarisDataSet.GetSizeY, vImarisDataSet.GetSizeZ]; - - -auxSc=vDataSize./(vDataMax-vDataMin); - - - - -%apply offset -tIdx=tIdx+offset(4); -vert=vert+repmat(offset(1:3),[size(vert,1) 1]);%apply translation offset - -%apply scaling -vert=vert./repmat(scale,[size(vert,1) 1]); - - -%convert points to Imaris frame units -vert=vert./repmat(auxSc,[size(vert,1) 1]); - - -%upload points to volume -aSpots.Set(vert,tIdx,radii);%we assume radius=1 -aSpots.SetTrackEdges(aEdges-1);%C-indexing diff --git a/linajea/visualization/imaris/ImarisCodeBase/setSpotsAllProperties.m b/linajea/visualization/imaris/ImarisCodeBase/setSpotsAllProperties.m deleted file mode 100644 index 4c87055..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/setSpotsAllProperties.m +++ /dev/null @@ -1,42 +0,0 @@ -%changes porperties of all spots in Imaris in a surpass scene (useful if we -%use one spot per lineage) - -%INPUT: -%color: [R,G,B,alpha] all between [0,1] (alpha = 0 most of teh time) -function setSpotsAllProperties(property,color,radius) - - -[vImarisApplication]=openImarisConnection; -aSurpassScene = vImarisApplication.GetSurpassScene; -numCh = aSurpassScene.GetNumberOfChildren; - -for kk = 0 : numCh-1 - aSpots = aSurpassScene.GetChild( kk ); - - %check if it is a spot - if( vImarisApplication.GetFactory.IsSpots( aSpots ) == false ) - continue; - end - - switch(property) - case 'track' %change thickness and color from the tracks - - - case 'points'%change thickness and color from the points - vRGBA = round(color * 255); % need integer values scaled to range 0-255 - vRGBA = uint32(vRGBA * [1; 256; 256*256; 256*256*256]); % combine different components (four bytes) into one integer - aSpots.SetColorRGBA(vRGBA); - vSpots = vImarisApplication.GetFactory.ToSpots( aSpots ); - aux = vSpots.GetRadiiXYZ; - aux = radius * ones(size(aux)); - vSpots.SetRadiiXYZ( aux ); - case 'uncheck' - aSpots.SetVisible( false ); - case 'check' - aSpots.SetVisible( true ); - otherwise - disp(['ERROR: Property ' property ' not define in the code']); - end - - break; -end \ No newline at end of file diff --git a/linajea/visualization/imaris/ImarisCodeBase/updateBatchToDatabase.m b/linajea/visualization/imaris/ImarisCodeBase/updateBatchToDatabase.m deleted file mode 100644 index 33102f1..0000000 --- a/linajea/visualization/imaris/ImarisCodeBase/updateBatchToDatabase.m +++ /dev/null @@ -1,152 +0,0 @@ -function updateBatchToDatabase(xMin,yMin,zMin,iniFrame,vert,tIdx,aEdges,experimentName,datasetName,proofReadSpots) - -userDB='Fernando' -ppssww='Pqqld5'; - - -%offset all the anootations - -%{ -1.-TIF datasets were read in Matlab using imread. -2.-Each dataset was cropped using: - im1(yMin:yMax,xMin:xMax,zMin:zMax) -3.-Offset=[xMin-1 yMin-1 zMin-1 iniFrame] %It seems Imaris also permutes x,y with respect to Matlab as V3D does -%} - - -offset=[xMin-1 yMin-1 zMin-1 iniFrame]; - - -%apply offset -vert=vert+repmat(offset(1:3),[size(vert,1) 1]);%apply translation offset -tIdx=tIdx+offset(4); -proofReadSpots=proofReadSpots+repmat(offset,[size(proofReadSpots,1) 1]); - -%----------------------------------------------------- -%open database connection if it is not open already -qq=mym('status'); -if(qq>0) - mym('open','localhost',userDB,ppssww); -end -mym('use tracking_cells');%set the appropriate database - -%------------------------------------------------------------------------------------ -%check if dataset exists in database. Otherwise add it - -sqlCmd=['SELECT dataset_id FROM datasets WHERE name = ''' datasetName ''' ']; -sqlVal=mym(sqlCmd); - -if(isempty(sqlVal.dataset_id)) - sqlCmd=['INSERT INTO datasets (name,comments) VALUES (''' datasetName ''',''none'')']; - mym(sqlCmd); - - qq=mym('SELECT LAST_INSERT_ID()'); - datasetId=getfield(qq,'LAST_INSERT_ID()'); -elseif(length(sqlVal.dataset_id)>1) - error 'Dataset name has more than one entry in database' -else - datasetId=sqlVal.dataset_id; -end - - - -%------------------------------------------------------------------------------ -%decide parent and children for each vertex -N=length(tIdx); -parentId=-ones(N,1);%-1 indicate no parent or no children (it will be null value in the database) -ch1=parentId; -ch2=parentId; - -for kk=1:size(aEdges,1) - e1=aEdges(kk,1); - e2=aEdges(kk,2); - - if(tIdx(e1)>tIdx(e2))%swap e1 and e2 - e2=aEdges(kk,1); - e1=aEdges(kk,2); - end - - %we assume e1 is in time t and e2 is in time t2 - if(parentId(e2)>0) - error 'Parent has already been assigned' - end - parentId(e2)=e1; - if(ch1(e1)<0) - ch1(e1)=e2; - else - if(ch2(e1)<0) - ch2(e1)=e2; - else - error 'Spot already has two children' - end - end -end - - -%create proof read array -proofReadCheck=zeros(N,1); -[idx dist]=knnsearch([vert tIdx],proofReadSpots); -if(max(dist)>1e-3) - error 'We can not find a match for some of the proof read spots' -end -proofReadCheck(idx)=1; - - -%check if experiment already exists in database. If it does, delete all the -%elements before uploading news -sqlCmd=['DELETE FROM centroids WHERE comments = ''' experimentName ''' ']; -mym(sqlCmd); - - - - -%update database -cellIdMap=zeros(N,1);%to map cell_id between Matlab and adtabase -for kk=1:N - - sqlCmd=['INSERT INTO centroids (x,y,z,time_point,dataset_id,proof_read,comments) VALUES (' ... - num2str(vert(kk,1)) ',' num2str(vert(kk,2)) ',' num2str(vert(kk,3)) ',' num2str(tIdx(kk)) ',' ... - num2str(datasetId) ',' num2str(proofReadCheck(kk)) ', ''' experimentName ''')']; - mym(sqlCmd); - - %obtain inserted id - qq=mym('SELECT LAST_INSERT_ID()'); - cellIdMap(kk)=getfield(qq,'LAST_INSERT_ID()'); -end - -%translate parentID and childrenID to database id (it should be a simple -%offset) and update record -parentId(parentId>0)=cellIdMap(parentId(parentId>0)); -ch1(ch1>0)=cellIdMap(ch1(ch1>0)); -ch2(ch2>0)=cellIdMap(ch2(ch2>0)); - -for kk=1:N - %update entries given that we have the cellId value - if(parentId(kk)<=0) - pp='null'; - else - pp=num2str(parentId(kk)); - end - - if(ch1(kk)<=0) - c1='null'; - else - c1=num2str(ch1(kk)); - end - - if(ch2(kk)<=0) - c2='null'; - else - c2=num2str(ch2(kk)); - end - - sqlCmd=['UPDATE centroids SET parent_id=' pp ',child1_id=' c1 ',child2_id=' c2 ' WHERE cell_id=' num2str(cellIdMap(kk))]; - mym(sqlCmd); -end - - - - - - - diff --git a/linajea/visualization/imaris/fixMamutParentIds.m b/linajea/visualization/imaris/fixMamutParentIds.m deleted file mode 100644 index 4e31e25..0000000 --- a/linajea/visualization/imaris/fixMamutParentIds.m +++ /dev/null @@ -1,25 +0,0 @@ -function trackingMatrix = fixMamutParentIds(trackingMatrix) - -parentIdsUnfixed = trackingMatrix(1:end, 7); - -for i = 1:length(trackingMatrix) - - currentParentId = parentIdsUnfixed(i); - - for j = 1:length(trackingMatrix) - - if trackingMatrix(j,1) == currentParentId && j > i - - trackingMatrix(j,7) = trackingMatrix(i,1); - - elseif trackingMatrix(j,8) == 0 - - trackingMatrix(j,7) = -1; - - end - - end -end - - - \ No newline at end of file diff --git a/linajea/visualization/imaris/getNumericalAttribute.m b/linajea/visualization/imaris/getNumericalAttribute.m deleted file mode 100644 index c1b88b8..0000000 --- a/linajea/visualization/imaris/getNumericalAttribute.m +++ /dev/null @@ -1,8 +0,0 @@ -function val=getNumericalAttribute(attributes,attrName) - -qq=attributes.getNamedItem(attrName); - -if(isempty(qq)) val=[];return;end; -val=str2num(qq.getValue); - -end \ No newline at end of file diff --git a/linajea/visualization/imaris/readCSV.m b/linajea/visualization/imaris/readCSV.m deleted file mode 100755 index a3eb980..0000000 --- a/linajea/visualization/imaris/readCSV.m +++ /dev/null @@ -1,64 +0,0 @@ -%we return the same structure as database from CATMAID so we can recycle -%code -table = readtable('tgmm.csv', "ReadVariableNames", false) - -%INPUT: csv with 7 columns: [id, t, z, y, x, parent_id, track_id] - -%OUTPUT: trackingMatrix Nx10 array, where N is the number of spots. -% We follow the SWC convention (more or less, since we need time): [id, type, x, y, z, radius, parent_id, time, confidence, skeletonId] -function trackingMatrix = readCSVData(filenameCSV) - -%read xml file -table = readtable(filenameCSV, "ReadVariableNames", false); - - -%extract spot information -N = height(csv); - -trackingMatrix = zeros(N,10); - -for ii=1:N - fstNode = NodeList.item(ii-1);%contains the ii=th Gaussian Mixture - - attrs = fstNode.getAttributes(); - - trackingMatrix(ii,:) = [getNumericalAttribute(attrs,'ID'), -1, getNumericalAttribute(attrs,'POSITION_X'), getNumericalAttribute(attrs,'POSITION_Y'),... - getNumericalAttribute(attrs,'POSITION_Z'), getNumericalAttribute(attrs,'RADIUS'), -1, getNumericalAttribute(attrs,'FRAME'),... - getNumericalAttribute(attrs,'QUALITY'), -1]; -end - -%extract linake information (parent_id and skeleton_id) -treenodeIdMap = containers.Map(trackingMatrix(:,1),[1:N]); - -NodeList = xDoc.getElementsByTagName('Track'); -Tr = NodeList.getLength(); - -for ii = 1:Tr - - fstTr = NodeList.item(ii-1);%contains the ii=th Gaussian Mixture - skeletonId = getNumericalAttribute(fstTr.getAttributes(), 'TRACK_ID' ); - - %parse all the edges inside a track - fstList = fstTr.getElementsByTagName('Edge'); - - E = fstList.getLength(); - for jj = 1:E - fstEdge = fstList.item(jj-1); - attr = fstEdge.getAttributes(); - parId = getNumericalAttribute(attr, 'SPOT_SOURCE_ID'); - chId = getNumericalAttribute(attr, 'SPOT_TARGET_ID'); - - trackingMatrix(treenodeIdMap(parId),10 ) = skeletonId; - trackingMatrix(treenodeIdMap(chId),10 ) = skeletonId; - trackingMatrix(treenodeIdMap(chId),7 ) = parId; - -% %self-verification: parent shuld always be earlier time point -% if( trackingMatrix(treenodeIdMap(parId),8) >= trackingMatrix(treenodeIdMap(chId),8) ) -% warning 'Parent has a later time point than child. It should not happen' -% end - end -end - -%sort elements in time -trackingMatrix = sortrows(trackingMatrix,8); -end diff --git a/linajea/visualization/imaris/readMamutXML.m b/linajea/visualization/imaris/readMamutXML.m deleted file mode 100644 index 8218a65..0000000 --- a/linajea/visualization/imaris/readMamutXML.m +++ /dev/null @@ -1,62 +0,0 @@ -%we return the same structure as database from CATMAID so we can recycle -%code - -%OUTPUT: trackingMatrix Nx10 array, where N is the number of spots. -% We follow the SWC convention (more or less, since we need time): [id, type, x, y, z, radius, parent_id, time, confidence, skeletonId] -function trackingMatrix = readMamutXML(filenameXML) - - -%read xml file -xDoc = xmlread(filenameXML); - - -%extract spot information -NodeList = xDoc.getElementsByTagName('Spot'); -N = NodeList.getLength(); - -trackingMatrix = zeros(N,10); - -for ii=1:N - fstNode = NodeList.item(ii-1);%contains the ii=th Gaussian Mixture - - attrs = fstNode.getAttributes(); - - trackingMatrix(ii,:) = [getNumericalAttribute(attrs,'ID'), -1, getNumericalAttribute(attrs,'POSITION_X'), getNumericalAttribute(attrs,'POSITION_Y'),... - getNumericalAttribute(attrs,'POSITION_Z'), getNumericalAttribute(attrs,'RADIUS'), -1, getNumericalAttribute(attrs,'FRAME'),... - getNumericalAttribute(attrs,'QUALITY'), -1]; -end - -%extract linake information (parent_id and skeleton_id) -treenodeIdMap = containers.Map(trackingMatrix(:,1),[1:N]); - -NodeList = xDoc.getElementsByTagName('Track'); -Tr = NodeList.getLength(); - -for ii = 1:Tr - - fstTr = NodeList.item(ii-1);%contains the ii=th Gaussian Mixture - skeletonId = getNumericalAttribute(fstTr.getAttributes(), 'TRACK_ID' ); - - %parse all the edges inside a track - fstList = fstTr.getElementsByTagName('Edge'); - - E = fstList.getLength(); - for jj = 1:E - fstEdge = fstList.item(jj-1); - attr = fstEdge.getAttributes(); - parId = getNumericalAttribute(attr, 'SPOT_SOURCE_ID'); - chId = getNumericalAttribute(attr, 'SPOT_TARGET_ID'); - - trackingMatrix(treenodeIdMap(parId),10 ) = skeletonId; - trackingMatrix(treenodeIdMap(chId),10 ) = skeletonId; - trackingMatrix(treenodeIdMap(chId),7 ) = parId; - -% %self-verification: parent shuld always be earlier time point -% if( trackingMatrix(treenodeIdMap(parId),8) >= trackingMatrix(treenodeIdMap(chId),8) ) -% warning 'Parent has a later time point than child. It should not happen' -% end - end -end - -%sort elements in time -trackingMatrix = sortrows(trackingMatrix,8); diff --git a/linajea/visualization/imaris/scriptExportMamut2imaris.m b/linajea/visualization/imaris/scriptExportMamut2imaris.m deleted file mode 100644 index 3260540..0000000 --- a/linajea/visualization/imaris/scriptExportMamut2imaris.m +++ /dev/null @@ -1,65 +0,0 @@ -%% -% This script imports MaMuT tracking data into Imaris. -% When running this script, ensure the folder ImarisCodeBase has been added -% to the folder path. - -% In Imaris, the Scene icon must be selected, otherwise there may be -% problems with the connection between Matlab and Imaris. - -%% -% Use this section to correct for any flips or 90' rotations in X,Y, or Z. -% You may have to play around with individual transpositions or -% combinations before you find which one is which. - - -transposeXY = true; -transposeXZ = false; -transposeYZ = false; -transposeinverse = false; - -%% -% This section loads the MaMuT .xml into a .mat file that Imaris will read - - -trackingM = readMamutXML("D:\Caroline\140521_extended_gt_deduplicated.xml"); % Location of the MaMuT .xml file containing MaMuT tracks either from manual annotations or TGMM objects - -if( transposeXY ) - trackingM(:,3:4) = trackingM(:,[4 3]); -end - -if( transposeXZ ) - trackingM(:,3:5) = trackingM(:,[5 4 3]); -end - -if( transposeYZ ) - trackingM(:,3:5) = trackingM(:,[3 5 4]); -end - - -if( transposeinverse ) - trackingM(:,3:5) = trackingM(:,[4 5 3]); -end - -%% - -stackRes = [1.0 1.0 5.0]; % X, Y, Z ratio for your dataset - -%upload to IMaris -%update tracks to Imaris -addpath ImarisCodeBase - -%create new spots entry in current Imaris scene -[vImarisApplication] = openImarisConnection; -aSurpassScene = vImarisApplication.GetSurpassScene; - -aSpots = vImarisApplication.GetFactory.CreateSpots; -aSurpassScene.AddChild( aSpots, -1 );%add at the end -idx = aSurpassScene.GetNumberOfChildren; -%set current spot as selected -vImarisApplication.SetSurpassSelection(aSurpassScene.GetChild(idx-1));%0-indexing -%vImarisApplication.GetSurpassSelection.SetName(name); %uncomment this -%line if you want to define a name - - -parseCATMAIDdbToImarisSpots(trackingM, stackRes,[], []); -rmpath ImarisCodeBase diff --git a/linajea/visualization/imaris/splitTrackingMatrixIntoTracks.m b/linajea/visualization/imaris/splitTrackingMatrixIntoTracks.m deleted file mode 100644 index 3dcd4c5..0000000 --- a/linajea/visualization/imaris/splitTrackingMatrixIntoTracks.m +++ /dev/null @@ -1,24 +0,0 @@ -function tracks = splitTrackingMatrixIntoTracks(trackingMatrix) - -trackIds = zeros(1,1); - -for i = 1:length(trackingMatrix) - if trackingMatrix(i,8) == 0 - trackIds(i) = trackingMatrix(i,10); - end -end - -trackNumber = length(trackIds); - -tracks = cell(trackNumber,1); - -for i = 1:length(trackIds) - n = 1; - currentTrackId = trackIds(i); - for j = 1:length(trackingMatrix) - if trackingMatrix(j,10) == currentTrackId - tracks{i}(n,1:10) = trackingMatrix(j,1:10); - n = n + 1; - end - end -end diff --git a/linajea/visualization/mamut/__init__.py b/linajea/visualization/mamut/__init__.py deleted file mode 100644 index 4f42a45..0000000 --- a/linajea/visualization/mamut/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import print_function, division, absolute_import -# flake8: noqa -from .mamut_writer import MamutWriter -from .mamut_reader import MamutReader -from .mamut_mongo_reader import MamutMongoReader -from .mamut_matched_tracks_reader import MamutMatchedTracksReader -from .mamut_file_reader import MamutFileReader -from .mamut_xml_templates import ( - track_template, - edge_template, - track_end_template, - allspots_template, - inframe_template, - spot_template, - inframe_end_template, - allspots_end_template, - begin_template, - alltracks_template, - alltracks_end_template, - filteredtracks_start_template, - filteredtracks_template, - filteredtracks_end_template, - end_template, - im_data_template, - ) diff --git a/linajea/visualization/mamut/mamut_file_reader.py b/linajea/visualization/mamut/mamut_file_reader.py deleted file mode 100644 index 2d11995..0000000 --- a/linajea/visualization/mamut/mamut_file_reader.py +++ /dev/null @@ -1,58 +0,0 @@ -from .mamut_reader import MamutReader -from linajea import parse_tracks_file -import networkx as nx -import logging - -logger = logging.getLogger(__name__) - - -class MamutFileReader(MamutReader): - def read_data(self, data): - filename = data['filename'] - locations, track_info = parse_tracks_file(filename) - graph = nx.DiGraph() - for loc, info in zip(locations, track_info): - node_id = info[0] - parent_id = info[1] - graph.add_node(node_id, position=loc.astype(int)) - if parent_id != -1: - graph.add_edge(node_id, parent_id) - - logger.info("Graph has %d nodes and %d edges" - % (len(graph.nodes), len(graph.edges))) - track_graphs = [graph.subgraph(g).copy() - for g in nx.weakly_connected_components(graph)] - logger.info("Found {} tracks".format(len(track_graphs))) - track_id = 0 - tracks = [] - cells = [] - for track in track_graphs: - if not track.nodes: - logger.info("track has no nodes. skipping") - continue - if not track.edges: - logger.info("track has no edges. skipping") - continue - for node_id, node in track.nodes(data=True): - position = node['position'] - score = node['score'] if 'score' in node else 0 - cells.append(self.create_cell(position, score, node_id)) - track_edges = [] - for u, v, edge in track.edges(data=True): - score = edge['score'] if 'score' in edge else 0 - track_edges.append(self.create_edge(u, - v, - score=score)) - cell_frames = [cell['position'][0] - for _, cell in track.nodes(data=True)] - start_time = min(cell_frames) - end_time = max(cell_frames) - - num_cells = len(track.nodes) - tracks.append(self.create_track(start_time, - end_time, - num_cells, - track_id, - track_edges)) - track_id += 1 - return cells, tracks diff --git a/linajea/visualization/mamut/mamut_matched_tracks_reader.py b/linajea/visualization/mamut/mamut_matched_tracks_reader.py deleted file mode 100644 index 23ebf8c..0000000 --- a/linajea/visualization/mamut/mamut_matched_tracks_reader.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import print_function, division, absolute_import -import logging -from .mamut_reader import MamutReader -import linajea -import linajea.evaluation -from daisy import Roi - -logger = logging.getLogger(__name__) - - -class MamutMatchedTracksReader(MamutReader): - def __init__(self, db_host): - super(MamutReader, self).__init__() - self.db_host = db_host - - def read_data(self, data): - if isinstance(data, tuple) and len(data) == 2: - (gt_tracks, matched_rec_tracks) = data - else: - candidate_db_name = data['db_name'] - start_frame, end_frame = data['frames'] - matching_threshold = data.get('matching_threshold', 20) - gt_db_name = data['gt_db_name'] - assert end_frame > start_frame - roi = Roi((start_frame, 0, 0, 0), - (end_frame - start_frame, 1e10, 1e10, 1e10)) - if 'parameters_id' in data: - try: - int(data['parameters_id']) - selected_key = 'selected_' + str(data['parameters_id']) - except: - selected_key = data['parameters_id'] - else: - selected_key = None - db = linajea.CandidateDatabase( - candidate_db_name, self.db_host) - db.selected_key = selected_key - gt_db = linajea.CandidateDatabase(gt_db_name, self.db_host) - - print("Reading GT cells and edges in %s" % roi) - gt_subgraph = gt_db[roi] - gt_graph = linajea.tracking.TrackGraph(gt_subgraph, frame_key='t') - gt_tracks = list(gt_graph.get_tracks()) - print("Found %d GT tracks" % len(gt_tracks)) - - # tracks_to_xml(gt_cells, gt_tracks, 'linajea_gt.xml') - - print("Reading cells and edges in %s" % roi) - subgraph = db.get_selected_graph(roi) - graph = linajea.tracking.TrackGraph(subgraph, frame_key='t') - tracks = list(graph.get_tracks()) - print("Found %d tracks" % len(tracks)) - - if len(graph.nodes) == 0 or len(gt_graph.nodes) == 0: - logger.info("Didn't find gt or reconstruction - returning") - return [], [] - - m = linajea.evaluation.match_edges( - gt_graph, graph, - matching_threshold=matching_threshold) - (edges_x, edges_y, edge_matches, edge_fps) = m - matched_rec_tracks = [] - for track in tracks: - for _, edge_index in edge_matches: - edge = edges_y[edge_index] - if track.has_edge(edge[0], edge[1]): - matched_rec_tracks.append(track) - break - logger.debug("found %d matched rec tracks" % len(matched_rec_tracks)) - - logger.info("Adding %d gt tracks" % len(gt_tracks)) - track_id = 0 - cells = [] - tracks = [] - for track in gt_tracks: - result = self.add_track(track, track_id, group=0) - print(result[0]) - if result is None or len(result[0]) == 0: - continue - track_cells, track = result - cells += track_cells - tracks.append(track) - track_id += 1 - - logger.info("Adding %d matched rec tracks" % len(matched_rec_tracks)) - for track in matched_rec_tracks: - result = self.add_track(track, track_id, group=1) - if result is None: - continue - track_cells, track = result - cells += track_cells - tracks.append(track) - track_id += 1 - - return cells, tracks - - def add_track(self, track, track_id, group): - if len(track.nodes) == 0: - logger.info("Track has no nodes. Skipping") - return None - - if len(track.edges) == 0: - logger.info("Track has no edges. Skipping") - return None - - cells = [] - invalid_cells = [] - for _id, data in track.nodes(data=True): - if _id == -1 or 't' not in data: - logger.info("Cell %s with data %s is not valid. Skipping" - % (_id, data)) - invalid_cells.append(_id) - continue - position = [data['t'], data['z'], data['y'], data['x']] - track.nodes[_id]['position'] = position - score = group - cells.append(self.create_cell(position, score, _id)) - - track.remove_nodes_from(invalid_cells) - - track_edges = [] - for u, v, edge in track.edges(data=True): - score = group - track_edges.append(self.create_edge(u, - v, - score=score)) - start_time, end_time = track.get_frames() - num_cells = len(track.nodes) - track = self.create_track(start_time, - end_time, - num_cells, - track_id, - track_edges) - return cells, track diff --git a/linajea/visualization/mamut/mamut_mongo_reader.py b/linajea/visualization/mamut/mamut_mongo_reader.py deleted file mode 100644 index fa9c901..0000000 --- a/linajea/visualization/mamut/mamut_mongo_reader.py +++ /dev/null @@ -1,154 +0,0 @@ -from __future__ import print_function, division, absolute_import -import logging -import networkx as nx -import linajea -import daisy -from .mamut_reader import MamutReader - -logger = logging.getLogger(__name__) - - -class MamutMongoReader(MamutReader): - def __init__(self, mongo_url): - super(MamutMongoReader, self).__init__() - self.mongo_url = mongo_url - - def read_nodes_and_edges( - self, - db_name, - frames=None, - nodes_key=None, - edges_key=None, - key=None, - filter_unattached=True): - db = linajea.CandidateDatabase(db_name, self.mongo_url) - if frames is None: - frames = [0, 1e10] - roi = daisy.Roi((frames[0], 0, 0, 0), - (frames[1] - frames[0], 1e10, 1e10, 1e10)) - if nodes_key is None: - nodes = db.read_nodes(roi) - else: - nodes = db.read_nodes(roi, attr_filter={nodes_key: True}) - node_ids = [node['id'] for node in nodes] - logger.debug("Found %d nodes" % len(node_ids)) - if edges_key is None and key is not None: - edges_key = key - if edges_key is None: - edges = db.read_edges(roi, nodes=nodes) - else: - edges = db.read_edges( - roi, nodes=nodes, attr_filter={edges_key: True}) - if filter_unattached: - logger.debug("Filtering cells") - filtered_cell_ids = set([edge['source'] for edge in edges] + - [edge['target'] for edge in edges]) - filtered_cells = [cell for cell in nodes - if cell['id'] in filtered_cell_ids] - nodes = filtered_cells - node_ids = filtered_cell_ids - logger.debug("Done filtering cells") - - logger.debug("Adjusting ids") - target_min_id = 0 - actual_min_id = min(node_ids) - diff = actual_min_id - target_min_id - logger.debug("Subtracting {} from all cell ids".format(diff)) - for node in nodes: - node['name'] = node['id'] - node['id'] -= diff - - for edge in edges: - edge['source'] -= diff - edge['target'] -= diff - - return nodes, edges - - def read_data(self, data): - - db_name = data['db_name'] - logger.debug("DB name: ", db_name) - group = data['group'] if 'group' in data else None - if 'parameters_id' in data: - try: - int(data['parameters_id']) - selected_key = 'selected_' + str(data['parameters_id']) - except: - selected_key = data['parameters_id'] - else: - selected_key = None - frames = data['frames'] if 'frames' in data else None - logger.debug("Selected key: ", selected_key) - nodes, edges = self.read_nodes_and_edges( - db_name, - frames=frames, - key=selected_key) - - if not nodes: - logger.error("No nodes found in database {}".format(db_name)) - return [], [] - - logger.info("Found %d nodes, %d edges in database" - % (len(nodes), len(edges))) - - cells = [] - for node in nodes: - position = [node['t'], node['z'], node['y'], node['x']] - if group is None: - score = node['score'] if 'score' in node else 0 - else: - score = group - cells.append(self.create_cell(position, score, node['id'], - name=node['name'])) - tracks = [] - if not edges: - logger.info("No edges in database. Skipping track formation.") - return cells, tracks - - graph = nx.DiGraph() - cell_ids = [] - for cell in cells: - if cell['id'] == -1: - continue - graph.add_node(cell['id'], **cell) - cell_ids.append(cell['id']) - for edge in edges: - if edge['target'] not in cell_ids\ - or edge['source'] not in cell_ids: - logger.info("Skipping edge %s with an end not in cell set" - % edge) - continue - graph.add_edge(edge['source'], edge['target'], **edge) - - logger.info("Graph has %d nodes and %d edges" - % (len(graph.nodes), len(graph.edges))) - track_graphs = [graph.subgraph(g).copy() - for g in nx.weakly_connected_components(graph)] - logger.info("Found {} tracks".format(len(track_graphs))) - track_id = 0 - for track in track_graphs: - if not track.nodes: - logger.info("track has no nodes. skipping") - continue - if not track.edges: - logger.info("track has no edges. skipping") - continue - track_edges = [] - for u, v, edge in track.edges(data=True): - score = edge['score'] if 'score' in edge else 0 - track_edges.append(self.create_edge(edge['source'], - edge['target'], - score=score)) - cell_frames = [cell['position'][0] - for _, cell in track.nodes(data=True)] - start_time = min(cell_frames) - end_time = max(cell_frames) - - num_cells = len(track.nodes) - tracks.append(self.create_track(start_time, - end_time, - num_cells, - track_id, - track_edges)) - track_id += 1 - return cells, tracks diff --git a/linajea/visualization/mamut/mamut_reader.py b/linajea/visualization/mamut/mamut_reader.py deleted file mode 100644 index 6093077..0000000 --- a/linajea/visualization/mamut/mamut_reader.py +++ /dev/null @@ -1,33 +0,0 @@ -import abc - -ABC = abc.ABCMeta('ABC', (object,), {'__slots__': ()}) - - -class MamutReader(ABC): - def __init__(self): - super(MamutReader, self).__init__() - - @abc.abstractmethod - def read_data(self, data): - # returns a tuple with (cells, edges) - pass - - def create_cell(self, position, score, _id, name=None): - cell = {'position': position, - 'score': score, - 'id': _id} - if name is not None: - cell['name'] = name - return cell - - def create_edge(self, source, target, score): - return {'source': source, - 'target': target, - 'score': score} - - def create_track(self, start, stop, num_cells, _id, edges): - return {'start': start, - 'stop': stop, - 'num_cells': num_cells, - 'id': _id, - 'edges': edges} diff --git a/linajea/visualization/mamut/mamut_writer.py b/linajea/visualization/mamut/mamut_writer.py deleted file mode 100644 index d3f9f74..0000000 --- a/linajea/visualization/mamut/mamut_writer.py +++ /dev/null @@ -1,206 +0,0 @@ -from __future__ import print_function, division, absolute_import -import os -import logging - -from .mamut_xml_templates import ( - begin_template, - alltracks_template, - alltracks_end_template, - filteredtracks_start_template, - filteredtracks_template, - filteredtracks_end_template, - end_template, - im_data_template, - inframe_template, - spot_template, - allspots_template, - allspots_end_template, - track_template, - edge_template, - track_end_template, - inframe_end_template, - ) - -logger = logging.getLogger(__name__) - - -class MamutWriter: - def __init__(self): - self.cells_by_frame = {} - self.max_cell_id = 0 - self.tracks = [] - - def remap_cell_ids(self, cells, tracks): - logger.info("Remapping cell ids") - id_map = {} - for cell in cells: - old_id = cell['id'] - id_map[old_id] = self.max_cell_id - cell['id'] = self.max_cell_id - self.max_cell_id += 1 - for track in tracks: - for edge in track['edges']: - old_source = edge['source'] - old_target = edge['target'] - edge['source'] = id_map[old_source] - edge['target'] = id_map[old_target] - logging.debug("Cell id map: {}".format(id_map)) - if self.max_cell_id > 2000000000: - logging.warn("Max ID after remapping %d is greater than 2 billion." - "Possible MaMuT error due to int overflow" - % self.max_cell_id) - - def add_data(self, mamut_reader, data): - if isinstance(data, tuple) and len(data) == 2: - cells, tracks = mamut_reader.read_data(data) - else: - cells, tracks = mamut_reader.read_data(data) - self.remap_cell_ids(cells, tracks) - logger.info("Adding %d cells, %d tracks" - % (len(cells), len(tracks))) - for cell in cells: - pos = cell['position'] - time = pos[0] - _id = cell['id'] - if time not in self.cells_by_frame: - self.cells_by_frame[time] = [] - self.cells_by_frame[time].append(cell) - if _id > self.max_cell_id: - self.max_cell_id = _id - - self.tracks.extend(tracks) - - def write(self, raw_data_xml, output_xml, scale=1.0): - if not self.cells_by_frame.keys(): - logger.error("No data to write. Exiting") - exit(1) - if not self.tracks: - logger.info("No tracks present. Creating fake track" - " to avoid MaMuT error.") - self.create_fake_track() - with open(output_xml, 'w') as output: - - output.write(begin_template) - - self.cells_to_xml(output, scale=scale) - - # Begin AllTracks. - output.write(alltracks_template) - logger.debug("Writing tracks {}".format(self.tracks)) - track_ids = [self.track_to_xml(track, _id, output) - for _id, track in enumerate(self.tracks)] - - # End AllTracks. - output.write(alltracks_end_template) - - # Filtered tracks. - output.write(filteredtracks_start_template) - for track_id in track_ids: - if track_id is not None: - output.write( - filteredtracks_template.format( - t_id=track_id - ) - ) - output.write(filteredtracks_end_template) - - # End XML file. - folder, filename = os.path.split(os.path.abspath(raw_data_xml)) - if not folder: - folder = os.path.dirname("./") - output.write(end_template.format( - image_data=im_data_template.format( - filename=filename, - folder=folder))) - - def cells_to_xml(self, output, scale=1.0): - num_cells = 0 - for frame in self.cells_by_frame.keys(): - num_cells += len(self.cells_by_frame[frame]) - # Begin AllSpots. - output.write(allspots_template.format(nspots=num_cells)) - - # Loop through lists of spots. - for t, cells in self.cells_by_frame.items(): - output.write(inframe_template.format(frame=t)) - for cell in cells: - _, z, y, x = cell['position'] - score = cell['score'] if 'score' in cell else 0 - _id = cell['id'] - if 'name' in cell: - name = cell['name'] - else: - # backwards compatible - name = str(_id) + " SPOT_" + str(_id) - output.write( - spot_template.format( - id=_id, - name=name, - frame=t, - quality=score, - z=z*scale, - y=y, - x=x)) - - output.write(inframe_end_template) - - # End AllSpots. - output.write(allspots_end_template) - - def track_to_xml(self, track, _id, output): - logger.debug("Writing track {}".format(track)) - edges = track['edges'] - - start = track['start'] - stop = track['stop'] - duration = stop - start + 1 - num_cells = track['num_cells'] - - track_id = _id - - output.write( - track_template.format( - id=track_id, - duration=duration, - start=start, stop=stop, - nspots=num_cells)) - if len(edges) == 0: - logger.warn("No edges in track!") - for edge in edges: - source = edge['source'] - target = edge['target'] - score = edge['score'] - - output.write( - edge_template.format( - source_id=source, target_id=target, - score=score, - # Shouldn't the time be edge specific, not track? - time=start)) - output.write(track_end_template) - - return track_id - - def create_fake_track(self): - start_time = list(self.cells_by_frame.keys())[0] - end_time = start_time + 1 - start_point = self.cells_by_frame[start_time][0] - end_point_position = [end_time] + start_point['position'][1:] - end_point_id = self.max_cell_id + 1 - self.max_cell_id = end_point_id - end_point = {'position': end_point_position, - 'score': start_point['score'], - 'id': end_point_id} - if end_time not in self.cells_by_frame: - self.cells_by_frame[end_time] = [] - self.cells_by_frame[end_time].append(end_point) - - edge = {'source': end_point_id, - 'target': start_point['id'], - 'score': 0} - track = {'start': start_time, - 'stop': end_time, - 'num_cells': 2, - 'id': 0, - 'edges': [edge]} - self.tracks.append(track) diff --git a/linajea/visualization/mamut/mamut_xml_templates.py b/linajea/visualization/mamut/mamut_xml_templates.py deleted file mode 100755 index 5e9b1fa..0000000 --- a/linajea/visualization/mamut/mamut_xml_templates.py +++ /dev/null @@ -1,130 +0,0 @@ -# Template pre-spots (root and features). -# flake8: noqa -begin_template = ''' - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \n''' - -# -# -# - -# Templates for spots. -allspots_template = ' \n' -inframe_template = ' \n' -spot_template = ' \n' -inframe_end_template = ' \n' -allspots_end_template = ' \n' -inframe_empty_template = ' \n' - -# Templates for tracks and edges. -alltracks_template = ' \n' -track_template = ' \n' -edge_template = ' \n' -# edge_template = ' \n' -track_end_template = ' \n' -alltracks_end_template = ' \n' - -# Templates for filtered tracks. -filteredtracks_start_template = ' \n' -filteredtracks_template = ' \n' -filteredtracks_end_template = ' \n' - - # \n \n ' - -# Template for ending the XML file. -im_data_template = '' -end_template = ''' - - - {image_data} - - - - - - - - - - - - - - - - - - - - - - - - - - - 0 - 50.0 - 1100.0 - -1 - 0 - - - - - 0 - 0.0 - 65535.0 - 0.0 - 65535.0 - 50.0 - 1100.0 - - - - - -''' - - # diff --git a/linajea/visualization/mamut/test/140521_raw.xml b/linajea/visualization/mamut/test/140521_raw.xml deleted file mode 100644 index 37bb553..0000000 --- a/linajea/visualization/mamut/test/140521_raw.xml +++ /dev/null @@ -1,2721 +0,0 @@ - - - . - - - - - - TM - - - - - - 0 - 0 - 2169 2048 988 - - µm - 1.0 1.0 5.0 - - - 0 - 0 - 0 - 0 - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 0 - 531 - - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - - 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 5.0 0.0 - - - - diff --git a/linajea/visualization/mamut/test/test_mamut_writer.xml b/linajea/visualization/mamut/test/test_mamut_writer.xml deleted file mode 100644 index 78d12cd..0000000 --- a/linajea/visualization/mamut/test/test_mamut_writer.xml +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 0 - 50.0 - 1100.0 - -1 - 0 - - - - - 0 - 0.0 - 65535.0 - 0.0 - 65535.0 - 50.0 - 1100.0 - - - - - - \ No newline at end of file diff --git a/linajea/visualization/mamut/test/test_mongo_reader.py b/linajea/visualization/mamut/test/test_mongo_reader.py deleted file mode 100644 index e5c410a..0000000 --- a/linajea/visualization/mamut/test/test_mongo_reader.py +++ /dev/null @@ -1,65 +0,0 @@ -from linajea.mamut_visualization import MamutWriter, MamutMongoReader -import logging -import pymongo as mongo -import sys - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def write_test_data(mongo_url, db_name): - client = mongo.MongoClient(mongo_url) - if db_name in client.list_database_names(): - logger.error("Database {} already exists. Exiting".format(db_name)) - return 1 - db = client[db_name] - nodes = db.get_collection('nodes') - edges = db.get_collection('edges') - nodes.insert_one( - {'id': 0, - 'score': 0, - 't': 0, - 'z': 1000, - 'y': 1000, - 'x': 1000} - ) - nodes.insert_one( - {'id': 1, - 'score': 0, - 't': 1, - 'z': 1010, - 'y': 1010, - 'x': 1010} - ) - - edges.insert_one( - {'id': 3, - 'distance': 0, - 'target': 0, - 'source': 1, - 'score': 0, - 'selected': True} - ) - - -def remove_test_data(mongo_url, db_name): - client = mongo.MongoClient(mongo_url) - if db_name not in client.list_database_names(): - logger.error("Database {} does not exist. Cannot drop".format(db_name)) - return 1 - client.drop_database(db_name) - - -if __name__ == "__main__": - mongo_url = sys.argv[1] - test_data_db_name = 'linajea_mamut_test' - write_test_data(mongo_url, test_data_db_name) - data = {'db_name': test_data_db_name, - 'frames': [0, 3], - 'selected_key': 'selected' - } - writer = MamutWriter() - reader = MamutMongoReader(mongo_url) - writer.add_data(reader, data) - writer.write('140521_raw.xml', 'test_mongo_reader.xml') - remove_test_data(mongo_url, test_data_db_name) diff --git a/linajea/visualization/mamut/test/test_mongo_reader.xml b/linajea/visualization/mamut/test/test_mongo_reader.xml deleted file mode 100644 index 44eb753..0000000 --- a/linajea/visualization/mamut/test/test_mongo_reader.xml +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 0 - 50.0 - 1100.0 - -1 - 0 - - - - - 0 - 0.0 - 65535.0 - 0.0 - 65535.0 - 50.0 - 1100.0 - - - - - - \ No newline at end of file diff --git a/linajea/visualization/mamut/test/test_writer.py b/linajea/visualization/mamut/test/test_writer.py deleted file mode 100644 index 25635ef..0000000 --- a/linajea/visualization/mamut/test/test_writer.py +++ /dev/null @@ -1,22 +0,0 @@ -from linajea.mamut_visualization import MamutWriter, MamutReader -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class TestReader(MamutReader): - def read_data(self, data): - cell1 = self.create_cell([0, 1, 2, 3], 0, 1) - cell2 = self.create_cell([1, 2, 3, 4], 0, 2) - cells = [cell1, cell2] - - edge1 = self.create_edge(2, 1, 0) - track1 = self.create_track(0, 1, 2, 3, [edge1]) - return cells, [track1] - - -if __name__ == "__main__": - writer = MamutWriter() - writer.add_data(TestReader(), None) - writer.write("140521_raw.xml", "test_mamut_writer.xml") diff --git a/linajea/visualization/spimagine/__init__.py b/linajea/visualization/spimagine/__init__.py deleted file mode 100644 index af57db6..0000000 --- a/linajea/visualization/spimagine/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# flake8: noqa -from .linajea_viewer import LinajeaViewer -from .get_tracks import ( - get_node_ids_in_frame, - get_track_from_node, - get_gt_tracks_for_roi, - get_region_around_node) diff --git a/linajea/visualization/spimagine/get_tracks.py b/linajea/visualization/spimagine/get_tracks.py deleted file mode 100644 index b4fa717..0000000 --- a/linajea/visualization/spimagine/get_tracks.py +++ /dev/null @@ -1,95 +0,0 @@ -import daisy -import linajea.tracking -import linajea -import pymongo - - -def get_gt_tracks_for_roi(gt_db_name, mongo_url, roi): - graph_provider = linajea.CandidateDatabase(gt_db_name, mongo_url) - subgraph = graph_provider[roi] - track_graph = linajea.tracking.TrackGraph(subgraph) - tracks = track_graph.get_tracks() - end_frame = roi.get_offset()[0] + roi.get_shape()[0] - 1 - one_d_tracks = [] - for track in tracks: - for end_cell in track.get_cells_in_frame(end_frame): - cell_positions = [] - current_cell = end_cell - while current_cell is not None: - current_data = track.nodes[current_cell] - cell_positions.append([current_data[dim] - for dim in ['t', 'z', 'y', 'x']]) - parent_edges = track.prev_edges(current_cell) - if len(parent_edges) == 1: - current_cell = parent_edges[0][1] - elif len(parent_edges) == 0: - current_cell = None - else: - print("Error: Cell has two parents! Exiting") - return None - one_d_tracks.append(cell_positions) - print("Found %d tracks in roi %s" % (len(one_d_tracks), roi)) - return one_d_tracks - - -def get_node_ids_in_frame(gt_db_name, mongo_url, frame): - graph_provider = linajea.CandidateDatabase(gt_db_name, mongo_url) - roi = daisy.Roi((frame, 0, 0, 0), (1, 10e6, 10e6, 10e6)) - nodes = graph_provider.read_nodes(roi) - node_ids = [node['id'] for node in nodes] - return node_ids - - -def get_track_from_node( - node_id, - node_frame, - gt_db_name, - mongo_url, - num_frames_before, - num_frames_after=0): - graph_provider = linajea.CandidateDatabase(gt_db_name, mongo_url) - roi = daisy.Roi((node_frame - num_frames_before + 1, 0, 0, 0), - (num_frames_before + num_frames_after, 10e6, 10e6, 10e6)) - subgraph = graph_provider[roi] - track_graph = linajea.tracking.TrackGraph(subgraph, frame_key='t') - tracks = track_graph.get_tracks() - for track in tracks: - if track.has_node(node_id): - cell_positions = [] - current_cell = node_id - while True: - current_data = track.nodes[current_cell] - if 't' not in current_data: - break - cell_positions.append([current_data[dim] - for dim in ['t', 'z', 'y', 'x']]) - parent_edges = list(track.prev_edges(current_cell)) - if len(parent_edges) == 1: - current_cell = parent_edges[0][1] - elif len(parent_edges) == 0: - break - else: - print("Error: Cell has two parents! Exiting") - return None - return cell_positions - print("Did not find track with node %d in roi %s" - % (node_id, roi)) - return None - - -def get_region_around_node( - node_id, - db_name, - db_host, - context, - voxel_size): - client = pymongo.MongoClient(db_host) - db = client[db_name] - nodes = db['nodes'] - node = nodes.find_one({'id': node_id}) - if node is None: - print("Did not fine node with %d in db %s" % (node_id, db_name)) - location = daisy.Coordinate([node[dim] for dim in ['t', 'z', 'y', 'x']]) - roi = daisy.Roi(location - context, context * 2) - roi = roi.snap_to_grid(voxel_size) - return roi diff --git a/linajea/visualization/spimagine/linajea_viewer.py b/linajea/visualization/spimagine/linajea_viewer.py deleted file mode 100644 index 6c4f983..0000000 --- a/linajea/visualization/spimagine/linajea_viewer.py +++ /dev/null @@ -1,354 +0,0 @@ -from PyQt5 import QtWidgets -import colorsys -import daisy -import logging -import math -import networkx as nx -import numpy as np -import spimagine -import sys - -logger = logging.getLogger(__name__) - - -class LinajeaViewer: - - def __init__( - self, - raw, - rec_graph_provider, - gt_graph_provider, - roi, - selected_key='selected', - channel=0): - - self.raw = raw - self.rec_graph = ( - rec_graph_provider[roi] - if rec_graph_provider else None) - self.gt_graph = ( - gt_graph_provider[roi] - if gt_graph_provider else None) - self.roi = roi - self.selected_key = selected_key - self.channel = 0 - - if self.rec_graph: - self.__propagate_selected_attributes(self.rec_graph) - self.__compute_spimagine_pos(self.rec_graph) - self.__label_tracks(self.rec_graph) - if self.gt_graph: - self.__compute_spimagine_pos(self.gt_graph) - - self.default_color = (0.2, 0.2, 0.2) - self.selected_color = (0.1, 1.0, 0.1) - self.nonselected_color = (1.0, 0.2, 0.5) - - self.show_nodes = True - self.show_edges = True - self.show_data = True - self.show_edge_attributes = [] - self.show_node_ids = False - self.show_node_scores = True - self.hide_non_selected = False - self.show_track_colors = False - self.show_track_ids = False - - self.limit_to_frames = (0, 0) - self.prev_t = -1 - self.t = roi.get_begin()[0] - - self.viewer = None - - def show(self): - - self.app = QtWidgets.QApplication(sys.argv) - self.viewer = self.__create_viewer() - self.draw_annotations() - - self.__block_until_closed() - - def clear_annotations(self): - - self.viewer.glWidget.meshes = [] - self.viewer.glWidget.lines = [] - self.viewer.glWidget.texts = [] - - def draw_annotations(self): - - self.limit_to_frames = (self.t - 0, self.t + 2) - - logger.debug("showing annotations in [%d:%d]" % self.limit_to_frames) - - if self.rec_graph is not None: - self.show_graph( - self.rec_graph) - - if self.gt_graph is not None: - self.show_graph( - self.gt_graph) - - def show_graph( - self, - graph): - - if self.show_nodes: - logger.debug("Adding %d node meshes...", graph.number_of_nodes()) - for node, data in graph.nodes(data=True): - self.__draw_node(node, data) - - if self.show_edges: - logger.debug("Adding %d edge lines...", graph.number_of_edges()) - for u, v, data in graph.edges(data=True): - self.__draw_edge(graph, u, v, data) - - def __block_until_closed(self): - - self.app.exec_() - del self.viewer - self.viewer = None - - def __propagate_selected_attributes(self, graph): - - for u, v, data in graph.edges(data=True): - - selected = self.selected_key in data and data[self.selected_key] - selected_u = graph.nodes[u].get(self.selected_key, False) - selected_v = graph.nodes[v].get(self.selected_key, False) - graph.nodes[u][self.selected_key] = selected or selected_u - graph.nodes[v][self.selected_key] = selected or selected_v - - for n, data in graph.nodes(data=True): - - if self.selected_key not in data: - data[self.selected_key] = False - - def __compute_spimagine_pos(self, graph): - - for node, data in graph.nodes(data=True): - if 'z' in data: - data['spimagine_pos'] = self.__to_spimagine_coords( - daisy.Coordinate((data[d] for d in ['z', 'y', 'x']))) - - def __label_tracks(self, graph): - - g = nx.Graph() - g.add_nodes_from(graph) - g.add_edges_from(graph.edges(data=True)) - delete_edges = [] - for u, v, data in g.edges(data=True): - selected = self.selected_key in data and data[self.selected_key] - if not selected: - delete_edges.append((u, v)) - g.remove_edges_from(delete_edges) - - components = nx.connected_components(g) - - for i, component in enumerate(components): - for node in component: - graph.nodes[node]['track'] = i + 1 - - def __create_viewer(self): - - raw_data = self.raw.to_ndarray(roi=self.roi, fill_value=0) - - if len(raw_data.shape) == 5: - raw_data = raw_data[self.channel] - if self.show_data: - viewer = spimagine.volshow( - raw_data, - stackUnits=self.raw.voxel_size[1:][::-1]) - viewer.set_colormap("grays") - else: - viewer = spimagine.volshow( - np.zeros(raw_data.shape), - stackUnits=self.raw.voxel_size[1:][::-1]) - - viewer.glWidget.transform._transformChanged.connect( - lambda: self.__on_transform_changed()) - - return viewer - - def __draw_node(self, node, node_data): - - if 'spimagine_pos' not in node_data: - return - - if ( - self.limit_to_frames and - node_data['t'] not in range( - self.limit_to_frames[0], - self.limit_to_frames[1])): - return - - if self.hide_non_selected: - if self.selected_key in node_data: - if not node_data[self.selected_key]: - return - - center = node_data['spimagine_pos'] - color = self.__get_node_color(node_data) - radius = self.__get_node_radius(node_data) - alpha = self.__get_node_alpha(node_data) - - self.viewer.glWidget.add_mesh( - spimagine.gui.mesh.SphericalMesh( - pos=center, - r=radius, - facecolor=color, - alpha=alpha)) - - text = self.__get_node_text(node, node_data) - - if text != "": - self.viewer.glWidget.add_text( - spimagine.gui.text.Text( - text, - pos=center, - color=color)) - - def __draw_edge(self, graph, u, v, edge_data): - - node_data_u = graph.nodes[u] - node_data_v = graph.nodes[v] - - if ( - 'spimagine_pos' not in node_data_u or - 'spimagine_pos' not in node_data_v): - return - - if ( - self.limit_to_frames and - node_data_u['t'] not in range( - self.limit_to_frames[0], - self.limit_to_frames[1])): - return - - if self.hide_non_selected: - if self.selected_key in edge_data: - if not edge_data[self.selected_key]: - return - - center_u = node_data_u['spimagine_pos'] - center_v = node_data_v['spimagine_pos'] - - width = self.__get_edge_width(edge_data) - color = self.__get_edge_color(edge_data) - alpha = self.__get_edge_alpha(edge_data) - - self.viewer.glWidget.add_lines( - spimagine.gui.lines.Lines( - [center_u, center_v], - width=width, - linecolor=color, - alpha=alpha)) - - text = self.__get_edge_text(edge_data) - - if text != "": - self.viewer.glWidget.add_text( - spimagine.gui.text.Text( - text, - pos=(center_u + center_v)*0.5)) - - def __on_transform_changed(self): - - t = self.viewer.glWidget.transform.dataPos - if t == self.prev_t: - return - self.prev_t = t - self.t = t + self.roi.get_begin()[0] - - logger.debug("t changed to %d" % self.t) - - self.clear_annotations() - self.draw_annotations() - - def __to_spimagine_coords(self, coordinate): - - coordinate = np.array(coordinate, dtype=np.float32) - - # relative to ROI begin - coordinate -= self.roi.get_begin()[1:] - # relative to ROI size in [0, 1] - coordinate /= np.array(self.roi.get_shape()[1:], dtype=np.float32) - # relative to ROI size in [-1, 1] - coordinate = coordinate*2 - 1 - # to xyz - return coordinate[::-1] - - def __get_node_color(self, node_data): - - if self.show_track_colors and 'track' in node_data: - return self.__id_to_color(node_data['track']) - - if self.selected_key in node_data: - if node_data[self.selected_key]: - return self.selected_color - else: - return self.nonselected_color - - return self.default_color - - def __get_node_radius(self, node_data): - - if node_data['t'] == self.t: - return 0.04 - return 0.01 - - def __get_node_alpha(self, node_data): - - if node_data['t'] == self.t: - return 0.9 - return 0.1 - - def __get_node_text(self, node, node_data): - - if node_data['t'] != self.t: - return "" - - text = [] - - if self.show_node_ids: - text.append("ID: %d" % node) - - if self.show_node_scores: - text.append("score: %.3f" % node_data['score']) - - if 'track' in node_data and self.show_track_ids: - text.append("track: %d" % node_data['track']) - - return ", ".join(text) - - def __get_edge_width(self, edge_data): - - return 5.0 - - def __get_edge_color(self, edge_data): - - if self.selected_key in edge_data: - if edge_data[self.selected_key]: - return self.selected_color - else: - return self.nonselected_color - - return self.default_color - - def __get_edge_alpha(self, edge_data): - - return 1.0 - - def __get_edge_text(self, edge_data): - - text = [ - "%s: %s" % (a, str(edge_data[a])) - for a in self.show_edge_attributes - ] - - return ", ".join(text) - - def __id_to_color(self, id_): - - h = id_*math.sqrt(2) - - return colorsys.hsv_to_rgb(h, 1.0, 1.0) From 49f2808a62d42748f10a4f0316c9573093aea0a3 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 1 Jul 2022 13:10:48 -0400 Subject: [PATCH 198/263] cleanup gunpowder nodes, prune to main method --- linajea/gunpowder_nodes/__init__.py | 4 +- ...ent_vectors.py => add_movement_vectors.py} | 107 ++++++++++------ linajea/gunpowder_nodes/cast.py | 42 +++++++ linajea/gunpowder_nodes/combine_channels.py | 27 +++- linajea/gunpowder_nodes/get_labels.py | 16 ++- linajea/gunpowder_nodes/no_op.py | 9 ++ linajea/gunpowder_nodes/normalize.py | 109 ++++++++-------- .../random_location_exclude_time.py | 23 +++- linajea/gunpowder_nodes/set_flag.py | 12 +- linajea/gunpowder_nodes/shift_augment.py | 19 ++- linajea/gunpowder_nodes/shuffle_channels.py | 15 ++- linajea/gunpowder_nodes/tracks_source.py | 51 +++++--- linajea/gunpowder_nodes/write_cells.py | 117 +++++++++++------- 13 files changed, 378 insertions(+), 173 deletions(-) rename linajea/gunpowder_nodes/{add_parent_vectors.py => add_movement_vectors.py} (74%) create mode 100644 linajea/gunpowder_nodes/cast.py diff --git a/linajea/gunpowder_nodes/__init__.py b/linajea/gunpowder_nodes/__init__.py index 530a43d..16b895f 100644 --- a/linajea/gunpowder_nodes/__init__.py +++ b/linajea/gunpowder_nodes/__init__.py @@ -1,6 +1,6 @@ # flake8: noqa from __future__ import absolute_import -from .add_parent_vectors import AddParentVectors +from .add_movement_vectors import AddMovementVectors from .shift_augment import ShiftAugment from .random_location_exclude_time import RandomLocationExcludeTime from .tracks_source import TracksSource @@ -9,4 +9,4 @@ from .no_op import NoOp from .get_labels import GetLabels from .set_flag import SetFlag -from .normalize import Clip, NormalizeMinMax, NormalizeMeanStd, NormalizeMedianMad +from .normalize import Clip, NormalizeAroundZero, NormalizeLowerUpper diff --git a/linajea/gunpowder_nodes/add_parent_vectors.py b/linajea/gunpowder_nodes/add_movement_vectors.py similarity index 74% rename from linajea/gunpowder_nodes/add_parent_vectors.py rename to linajea/gunpowder_nodes/add_movement_vectors.py index 4cdff28..0fe9047 100644 --- a/linajea/gunpowder_nodes/add_parent_vectors.py +++ b/linajea/gunpowder_nodes/add_movement_vectors.py @@ -1,3 +1,9 @@ +"""Provides a gunpowder node to add movement vectors to pipeline +""" +import logging + +import numpy as np + from gunpowder import BatchFilter from gunpowder.array import Array from gunpowder.array_spec import ArraySpec @@ -6,22 +12,49 @@ from gunpowder.morphology import enlarge_binary_map from gunpowder.graph_spec import GraphSpec from gunpowder.roi import Roi -import logging -import numpy as np -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) -class AddParentVectors(BatchFilter): +class AddMovementVectors(BatchFilter): + """Gunpowder node to add movement vectors + + Takes a list of points and their parent and draws movement vectors + into a new array + + Attributes + ---------- + points: gp.GraphKey + Gunpowder graph object containing a graph of points and their + parents + array: gp.ArrayKey + Empty gunpowder array object, movement vectors will be drawn + into this + mask: gp.ArrayKey + Empty gunpowder array object, binary mask, around each point + a disk will be drawn, its size depends on the given object radius. + The movement vectors will only be drawn were this mask is set. + object_radius: + Fixed size of the radius of each object disk drawn. + Not used if graph contains object specific radii. + move_radius: + How far an object can move between two frames, increase read ROI + by this much, to avoid that parent nodes are outside and skipped. + array_spec: gp.ArraySpec + ArraySpec to use for movement vectors array. Should be used to + set voxel size if data is anisotropic + dense: bool + Is every object contained in ground truth annotations? + """ def __init__( - self, points, array, mask, radius, + self, points, array, mask, object_radius, move_radius=0, array_spec=None, dense=False): self.points = points self.array = array self.mask = mask - self.radius = np.array([radius]).flatten().astype(np.float32) + self.object_radius = np.array([object_radius]).flatten().astype(np.float32) self.move_radius = move_radius if array_spec is None: self.array_spec = ArraySpec() @@ -51,7 +84,7 @@ def setup(self): self.enable_autoskip() def prepare(self, request): - context = np.ceil(self.radius).astype(int) + context = np.ceil(self.object_radius).astype(int) dims = self.array_spec.roi.dims() if len(context) == 1: @@ -65,7 +98,7 @@ def prepare(self, request): for i in range(1, len(context)): context[i] = max(context[i], self.move_radius) - logger.debug ("parent vector context %s", context) + logger.debug ("movement vector context %s", context) # request points in a larger area points_roi = request[self.array].roi.grow( Coordinate(context), @@ -101,22 +134,22 @@ def process(self, batch, request): logger.debug("Data roi in voxels: %s", data_roi) logger.debug("Data roi in world units: %s", data_roi*voxel_size) - parent_vectors_data, mask_data = self.__draw_parent_vectors( + movement_vectors_data, mask_data = self.__draw_movement_vectors( points, data_roi, voxel_size, enlarged_vol_roi.get_begin(), - self.radius, + self.object_radius, request[self.points].roi) # create array and crop it to requested roi spec = self.spec[self.array].copy() spec.roi = data_roi*voxel_size - parent_vectors = Array( - data=parent_vectors_data, + movement_vectors = Array( + data=movement_vectors_data, spec=spec) - logger.debug("Cropping parent vectors to %s", request[self.array].roi) - batch.arrays[self.array] = parent_vectors.crop(request[self.array].roi) + logger.debug("Cropping movement vectors to %s", request[self.array].roi) + batch.arrays[self.array] = movement_vectors.crop(request[self.array].roi) # create mask and crop it to requested roi spec = self.spec[self.mask].copy() @@ -139,8 +172,8 @@ def process(self, batch, request): logger.warning("Returning empty batch for key %s and roi %s" % (self.points, request_roi)) - def __draw_parent_vectors( - self, points, data_roi, voxel_size, offset, radius, final_roi): + def __draw_movement_vectors(self, points, data_roi, voxel_size, offset, + object_radius, final_roi): # 4D: t, z, y, x shape = data_roi.get_shape() @@ -167,27 +200,29 @@ def __draw_parent_vectors( coords[1, :] += offset[2] coords[2, :] += offset[3] - parent_vectors = np.zeros_like(coords) + movement_vectors = np.zeros_like(coords) mask = np.zeros(shape, dtype=bool) logger.debug( - "Adding parent vectors for %d points...", + "Adding movement vectors for %d points...", points.num_vertices()) if points.num_vertices() == 0: - return parent_vectors, mask.astype(np.float32) + return movement_vectors, mask.astype(np.float32) empty = True cnt = 0 total = 0 - avg_radius = [] + # if object specific radius is contained in data, compute average + # radius for current frame and use that to draw mask and vectors + avg_object_radius = [] for point in points.nodes: if point.attrs.get('value') is not None: r = point.attrs['value'][0] - avg_radius.append(point.attrs['value'][0]) - avg_radius = (int(np.ceil(np.mean(avg_radius))) - if len(avg_radius) > 0 else radius) + avg_object_radius.append(r) + avg_object_radius = (int(np.ceil(np.mean(avg_object_radius))) + if len(avg_object_radius) > 0 else object_radius) for point in points.nodes: # get the voxel coordinate, 'Coordinate' ensures integer @@ -220,7 +255,7 @@ def __draw_parent_vectors( enlarge_binary_map( mask, - avg_radius, + avg_object_radius, voxel_size, in_place=True) @@ -228,7 +263,7 @@ def __draw_parent_vectors( mask_tmp[shape//2] = 1 enlarge_binary_map( mask_tmp, - avg_radius, + avg_object_radius, voxel_size, in_place=True) @@ -305,20 +340,20 @@ def __draw_parent_vectors( point_mask[:] = 0 point_mask[slices] = mask_cut[mc_slices] - parent_vectors[0][point_mask] = (parent.location[1] - - coords[0][point_mask]) - parent_vectors[1][point_mask] = (parent.location[2] - - coords[1][point_mask]) - parent_vectors[2][point_mask] = (parent.location[3] - - coords[2][point_mask]) + movement_vectors[0][point_mask] = (parent.location[1] + - coords[0][point_mask]) + movement_vectors[1][point_mask] = (parent.location[2] + - coords[1][point_mask]) + movement_vectors[2][point_mask] = (parent.location[3] + - coords[2][point_mask]) - parent_vectors[0][np.logical_not(mask)] = 0 - parent_vectors[1][np.logical_not(mask)] = 0 - parent_vectors[2][np.logical_not(mask)] = 0 + movement_vectors[0][np.logical_not(mask)] = 0 + movement_vectors[1][np.logical_not(mask)] = 0 + movement_vectors[2][np.logical_not(mask)] = 0 if empty: - logger.warning("No parent vectors written for points %s" + logger.warning("No movement vectors written for points %s" % points.nodes) logger.debug("written {}/{}".format(cnt, total)) - return parent_vectors, mask.astype(np.float32) + return movement_vectors, mask.astype(np.float32) diff --git a/linajea/gunpowder_nodes/cast.py b/linajea/gunpowder_nodes/cast.py new file mode 100644 index 0000000..afd203b --- /dev/null +++ b/linajea/gunpowder_nodes/cast.py @@ -0,0 +1,42 @@ +"""Provides a gunpowder node to cast array from one type to another +""" +import gunpowder as gp +import numpy as np + +class Cast(gp.BatchFilter): + """Gunpowder node to cast array to dtype + + Attributes + ---------- + array: gp.ArrayKey + array to cast to new type + dtype: np.dtype + new dtype of array + """ + def __init__( + self, + array, + dtype=np.float32): + + self.array = array + self.dtype = dtype + + def setup(self): + self.enable_autoskip() + array_spec = copy.deepcopy(self.spec[self.array]) + array_spec.dtype = self.dtype + self.updates(self.array, array_spec) + + def prepare(self, request): + deps = gp.BatchRequest() + deps[self.array] = request[self.array] + deps[self.array].dtype = None + return deps + + def process(self, batch, request): + if self.array not in batch.arrays: + return + + array = batch.arrays[self.array] + array.spec.dtype = self.dtype + array.data = array.data.astype(self.dtype) diff --git a/linajea/gunpowder_nodes/combine_channels.py b/linajea/gunpowder_nodes/combine_channels.py index 1f7608a..975037f 100644 --- a/linajea/gunpowder_nodes/combine_channels.py +++ b/linajea/gunpowder_nodes/combine_channels.py @@ -1,12 +1,35 @@ -from gunpowder import BatchFilter, ArraySpec, Array -import numpy as np +"""Provides a gunpowder node to combine multiple channels into single array +""" import logging +import numpy as np + +from gunpowder import BatchFilter, ArraySpec, Array + logger = logging.getLogger(__name__) class CombineChannels(BatchFilter): + """Gunpowder node to take several arrays and combine them into a + multichannel array + + Could be used if, e.g., rgb channels or multiple time frames are + stored separately. + + Attributes + ---------- + channel_1: gp.ArrayKey + channel_2: gp.ArrayKey + The two arrays to combine + output: gp.ArrayKey + The combined array will be stored here + transpose: bool + Shuffle order of channels + Notes + ----- + TODO: generalize for N channels (list of channels) + """ def __init__( self, channel_1, diff --git a/linajea/gunpowder_nodes/get_labels.py b/linajea/gunpowder_nodes/get_labels.py index 3b935ad..f206ef4 100644 --- a/linajea/gunpowder_nodes/get_labels.py +++ b/linajea/gunpowder_nodes/get_labels.py @@ -1,10 +1,24 @@ +"""Provides a gunpowder node to set labels +""" import numpy as np import gunpowder as gp class GetLabels(gp.BatchFilter): - + """Gunpowder node to extract label from attribute set on raw array + + Used in combination with gp.SpecifiedLocation, expects + specified_location_extra_data attribute on raw gp.Array to be set + + Attributes + ---------- + raw: gp.ArrayKey + Input array containing raw data, needs to have + specified_location_extra_data attribute set + labels: gp.ArrayKey + Output array + """ def __init__(self, raw, labels): self.raw = raw diff --git a/linajea/gunpowder_nodes/no_op.py b/linajea/gunpowder_nodes/no_op.py index d99c545..4ed71d7 100644 --- a/linajea/gunpowder_nodes/no_op.py +++ b/linajea/gunpowder_nodes/no_op.py @@ -1,8 +1,17 @@ +"""Provides a gunpowder node that does nothing +""" import gunpowder as gp class NoOp(gp.BatchFilter): + """Gunpowder node that does nothing, passes through data + Can be used to create optional nodes in pipeline: + + start_of_pipeline + + (gp.SomeNode(...) if flag else gp.NoOp) + + rest_of_pipeline + """ def __init__(self): pass diff --git a/linajea/gunpowder_nodes/normalize.py b/linajea/gunpowder_nodes/normalize.py index e8d1f44..01e088d 100644 --- a/linajea/gunpowder_nodes/normalize.py +++ b/linajea/gunpowder_nodes/normalize.py @@ -1,9 +1,21 @@ +"""Provides gunpowder nodes for data clipping and normalization +""" import gunpowder as gp import numpy as np class Clip(gp.BatchFilter): - + """Gunpowder node to clip data in array to range + + Attributes + ---------- + array: gp.ArrayKey + data to clip + mn: float + lower bound + mx: float + upper bound + """ def __init__(self, array, mn=None, mx=None): self.array = array @@ -28,50 +40,31 @@ def process(self, batch, request): array.data = np.clip(array.data, self.mn, self.mx) -class NormalizeMinMax(gp.Normalize): - +class NormalizeAroundZero(gp.Normalize): + """Gunpowder node to normalize data in array with mean and std + + Attributes + ---------- + array: gp.ArrayKey + data to normalize + mapped_to_zero: float + pixels with this value be mapped to 0 (typically mean) + diff_mapped_to_one: float + pixels with an absolute difference of this value to the + mapped_to_zero value will be mapped to +-1 (typically std) + dtype: np.dtype + cast output array to this type + """ def __init__( self, array, - mn, - mx, - interpolatable=True, - dtype=np.float32, - clip=False): - - super(NormalizeMinMax, self).__init__(array, interpolatable=interpolatable) - - self.array = array - self.mn = mn - self.mx = mx - self.dtype = dtype - self.clip = clip - - def process(self, batch, request): - if self.array not in batch.arrays: - return - - array = batch.arrays[self.array] - array.spec.dtype = self.dtype - array.data = array.data.astype(self.dtype) - if self.clip: - array.data = np.clip(array.data, self.mn, self.mx) - array.data = (array.data - self.mn) / (self.mx - self.mn) - array.data = array.data.astype(self.dtype) - - -class NormalizeMeanStd(gp.Normalize): - - def __init__( - self, - array, - mean, - std, + mapped_to_zero, + diff_mapped_to_one, dtype=np.float32): self.array = array - self.mean = mean - self.std = std + self.mapped_to_zero = mapped_to_zero + self.diff_mapped_to_one = diff_mapped_to_one self.dtype = dtype def process(self, batch, request): @@ -81,30 +74,30 @@ def process(self, batch, request): array = batch.arrays[self.array] array.spec.dtype = self.dtype array.data = array.data.astype(self.dtype) - array.data = (array.data - self.mean) / self.std + array.data = (array.data - self.mapped_to_zero) / self.diff_mapped_to_one array.data = array.data.astype(self.dtype) -class NormalizeMedianMad(gp.Normalize): - +class NormalizeLowerUpper(NormalizeAroundZero): + """Gunpowder node to normalize data in array from range to [0, 1] + + Attributes + ---------- + array: gp.Array + data to normalize + lower: float + lower bound, will be mapped to 0 + upper: float + upper bound, will be mapped to 1 + dtype: np.dtype + cast output array to this type + """ def __init__( self, array, - median, - mad, + lower, + upper, + interpolatable=True, dtype=np.float32): - self.array = array - self.median = median - self.mad = mad - self.dtype = dtype - - def process(self, batch, request): - if self.array not in batch.arrays: - return - - array = batch.arrays[self.array] - array.spec.dtype = self.dtype - array.data = array.data.astype(self.dtype) - array.data = (array.data - self.median) / self.mad - array.data = array.data.astype(self.dtype) + super(NormalizeLowerUpper, self).__init__(array, lower, upper-lower) diff --git a/linajea/gunpowder_nodes/random_location_exclude_time.py b/linajea/gunpowder_nodes/random_location_exclude_time.py index 274ddd7..91991b6 100644 --- a/linajea/gunpowder_nodes/random_location_exclude_time.py +++ b/linajea/gunpowder_nodes/random_location_exclude_time.py @@ -1,3 +1,6 @@ +"""Provides gunpowder node to exclude time range when selecting + random location +""" import gunpowder as gp import logging @@ -5,8 +8,24 @@ class RandomLocationExcludeTime(gp.RandomLocation): - ''' Provide list of time intervals to exclude. - time interval is like an array slice - includes start, excludes end ''' + """Adapts Gunpowder RandomLocation node to exclude time interval + + Provide list of time intervals to exclude. + time interval is like an array slice - includes start, excludes end + + Attributes + ---------- + raw: ArrayKey + verify that Roi of this array is outside of all given intervals + time_interval: list of list of int + list of intervals to exclude + min_masked: float + mask: ArrayKey + ensure_nonempty: GraphKey + p_nonempty: float + point_balance_radius: int + Pass these through to RandomLocation constructor + """ def __init__( self, diff --git a/linajea/gunpowder_nodes/set_flag.py b/linajea/gunpowder_nodes/set_flag.py index 33a7c7a..b52d882 100644 --- a/linajea/gunpowder_nodes/set_flag.py +++ b/linajea/gunpowder_nodes/set_flag.py @@ -1,8 +1,18 @@ +"""Provides gunpowder node to set flag accessible in remaining pipeline +""" import gunpowder as gp class SetFlag(gp.BatchFilter): - + """Gunpowder node to create a new array and set it to a specific value + + Attributes + ---------- + key: gp.ArrayKey + create a new array with this key + value: object + assign this value to new array + """ def __init__(self, key, value): self.key = key diff --git a/linajea/gunpowder_nodes/shift_augment.py b/linajea/gunpowder_nodes/shift_augment.py index 4856e66..82334b1 100644 --- a/linajea/gunpowder_nodes/shift_augment.py +++ b/linajea/gunpowder_nodes/shift_augment.py @@ -1,22 +1,24 @@ -from __future__ import print_function, division +"""Provides an adapted version of the gunpowder ShiftAugment node +""" import logging -import numpy as np import random + +import numpy as np + from gunpowder.roi import Roi from gunpowder.coordinate import Coordinate from gunpowder.batch_request import BatchRequest - from gunpowder import BatchFilter logger = logging.getLogger(__name__) class ShiftAugment(BatchFilter): - ''' - This class differs from the gunpowder ShiftAugment by only - shifting between frame 0 and frame -1 - ''' + """Modification of Gunpowder ShiftAugment, only shift/slip center + This class differs from the gunpowder ShiftAugment by only + slipping/shifting at the center frame. + """ def __init__( self, prob_slip=0, @@ -242,7 +244,6 @@ def shift_points( """ nodes = list(points.nodes) - logger.debug("nodes before %s", nodes) spec = points.spec shift_axis_start_pos = spec.roi.get_offset()[shift_axis] @@ -253,12 +254,10 @@ def shift_points( // lcm_voxel_size[shift_axis]) assert(shift_array_index >= 0) shift = Coordinate(sub_shift_array[shift_array_index]) - # TODO check loc += shift if not request_roi.contains(loc): points.remove_node(node) - logger.debug("nodes after %s", nodes) points.spec.roi = request_roi return points diff --git a/linajea/gunpowder_nodes/shuffle_channels.py b/linajea/gunpowder_nodes/shuffle_channels.py index 6f52b60..9936ccc 100644 --- a/linajea/gunpowder_nodes/shuffle_channels.py +++ b/linajea/gunpowder_nodes/shuffle_channels.py @@ -1,12 +1,23 @@ -from gunpowder import BatchFilter, Array -import numpy as np +"""Provides a gunpowder node to shuffle the order of input channels +""" import logging +import numpy as np + +from gunpowder import BatchFilter, Array + logger = logging.getLogger(__name__) class ShuffleChannels(BatchFilter): + """Gunpowder node to shuffle input channels + Attributes + ---------- + source: gp.ArrayKey + array whose channels should be shuffled, expects channels along + first axis + """ def __init__( self, source): diff --git a/linajea/gunpowder_nodes/tracks_source.py b/linajea/gunpowder_nodes/tracks_source.py index 016226b..5474db9 100644 --- a/linajea/gunpowder_nodes/tracks_source.py +++ b/linajea/gunpowder_nodes/tracks_source.py @@ -1,8 +1,12 @@ +"""Provides a gunpowder source node for tracks +""" +import logging + +import numpy as np + from gunpowder import (Node, Coordinate, Batch, BatchProvider, Roi, GraphSpec, Graph) from gunpowder.profiling import Timing -import numpy as np -import logging from linajea.utils import parse_tracks_file @@ -10,7 +14,20 @@ class TrackNode(Node): - + """Specializes gp.Node to set a number of attributes + + Attributes + ---------- + original_location: np.ndarray + location of node + parent_id: int + id of parent node in track that node is part of + track_id: int + id of track that node is part of + value: object + some value that should be associated with node, + e.g., used to store object specific radius + """ def __init__(self, id, location, parent_id, track_id, value=None): attrs = {"original_location": np.array(location, dtype=np.float32), @@ -20,7 +37,7 @@ def __init__(self, id, location, parent_id, track_id, value=None): super(TrackNode, self).__init__(id, location, attrs=attrs) class TracksSource(BatchProvider): - '''Read tracks of points from a comma-separated-values text file. + '''Gunpowder source node: read tracks of points from a csv file. If possible, this node uses the header of the file to determine values. If present, a header must have the following required fields: @@ -137,27 +154,31 @@ def _get_points(self, point_filter): nodes = [] for location, track_info in zip(filtered_locations, filtered_track_info): + # frame of current point t = location[0] - if isinstance(self.use_radius, dict): + if not isinstance(self.use_radius, dict): + # if use_radius is boolean, take radius from file if set + value = track_info[3] if self.use_radius else None + else: + # otherwise use_radius should be a dict mapping from + # frame thresholds to radii if len(self.use_radius.keys()) > 1: value = None for th in sorted(self.use_radius.keys()): + # find entry that is closest but larger than + # frame of current point if t < int(th): + # get value object (list) from track info value = track_info[3] - - try: - value[0] = self.use_radius[th] - except TypeError as e: - print(value, self.use_radius, track_info, - self.filename) - raise e + # radius stored at first position (None if not set) + value[0] = self.use_radius[th] break - assert value is not None, "verify use_radius in config" + assert value is not None, \ + "verify value of use_radius in config" else: value = (track_info[3] if list(self.use_radius.values())[0] else None) - else: - value = track_info[3] if self.use_radius else None + node = TrackNode( # point_id track_info[0], diff --git a/linajea/gunpowder_nodes/write_cells.py b/linajea/gunpowder_nodes/write_cells.py index 01deb01..306d917 100644 --- a/linajea/gunpowder_nodes/write_cells.py +++ b/linajea/gunpowder_nodes/write_cells.py @@ -1,20 +1,56 @@ -from funlib import math -import gunpowder as gp -import numpy as np +"""Provides a gunpowder node to write node indicators and movement vectors to database +""" +import logging import pymongo from pymongo.errors import BulkWriteError -import logging + +import numpy as np + +from funlib import math +import gunpowder as gp logger = logging.getLogger(__name__) class WriteCells(gp.BatchFilter): - + """Gunpowder node to write tracking prediction data to database + + Attributes + ---------- + maxima: gp.ArrayKey + binary array containing extracted maxima + cell_indicator: gp.ArrayKey + array containing cell indicator prediction + movement_vectors: gp.ArrayKey + array containing movement vector prediction + score_threshold: float + ignore maxima with a cell indicator score lower than this + db_host: str + mongodb host + db_name: str + write to this database + edge_length: int + if > 1, edge length indicates the length of the edge of a cube + from which movement vectors will be read. The cube will be + centered around the maxima, and predictions within the cube + of voxels will be averaged to get the movement vector to store + in the db + mask: np.ndarray + If not None, use as mask and ignore all predictions outside of + mask + z_range: 2-tuple + If not None, ignore all predictions ouside of the given + z/depth range + volume_shape: gp.Coordinate or list of int + If not None, should be set to shape of volume (in voxels); + ignore all predictions outside (might occur if using daisy with + overhang) + """ def __init__( self, maxima, cell_indicator, - parent_vectors, + movement_vectors, score_threshold, db_host, db_name, @@ -22,17 +58,10 @@ def __init__( mask=None, z_range=None, volume_shape=None): - '''Edge length indicates the length of the edge of the cube - from which parent vectors will be read. The cube will be centered - around the maxima, and predictions within the cube of voxels - will be averaged to get the parent vector to store in the db - If (binary) mask or z_range is provided, only cells within mask - or range will be written - ''' self.maxima = maxima self.cell_indicator = cell_indicator - self.parent_vectors = parent_vectors + self.movement_vectors = movement_vectors self.score_threshold = score_threshold self.db_host = db_host self.db_name = db_name @@ -69,20 +98,20 @@ def process(self, batch, request): maxima = batch[self.maxima].data cell_indicator = batch[self.cell_indicator].data - parent_vectors = batch[self.parent_vectors].data + movement_vectors = batch[self.movement_vectors].data cells = [] for index in np.argwhere(maxima*cell_indicator > self.score_threshold): index = gp.Coordinate(index) - logger.debug("Getting parent vector at index %s" % str(index)) + logger.debug("Getting movement vector at index %s" % str(index)) score = cell_indicator[index] if self.edge_length == 1: - parent_vector = tuple( - float(x) for x in parent_vectors[(Ellipsis,) + index]) + movement_vector = tuple( + float(x) for x in movement_vectors[(Ellipsis,) + index]) else: - parent_vector = WriteCells.get_avg_pv( - parent_vectors, index, self.edge_length) + movement_vector = WriteCells.get_avg_mv( + movement_vectors, index, self.edge_length) position = roi.get_begin() + voxel_size*index if self.volume_shape is not None and \ np.any(np.greater_equal( @@ -93,13 +122,13 @@ def process(self, batch, request): if self.mask is not None: tmp_pos = position // voxel_size if self.mask[tmp_pos[-self.mask.ndim:]] == 0: - logger.info("skipping cell mask {}".format(tmp_pos)) + logger.debug("skipping cell mask {}".format(tmp_pos)) continue if self.z_range is not None: tmp_pos = position // voxel_size if tmp_pos[1] < self.z_range[0] or \ tmp_pos[1] > self.z_range[1]: - logger.info("skipping cell zrange {}".format(tmp_pos)) + logger.debug("skipping cell zrange {}".format(tmp_pos)) continue cell_id = int(math.cantor_number( @@ -112,12 +141,12 @@ def process(self, batch, request): 'z': position[1], 'y': position[2], 'x': position[3], - 'parent_vector': parent_vector + 'movement_vector': movement_vector }) logger.debug( - "ID=%d, score=%f, parent_vector=%s" % ( - cell_id, score, parent_vector)) + "ID=%d, score=%f, movement_vector=%s" % ( + cell_id, score, movement_vector)) if len(cells) > 0: try: @@ -127,54 +156,54 @@ def process(self, batch, request): raise - def get_avg_pv(parent_vectors, index, edge_length): - ''' Computes the average parent vector offset from the parent vectors - in a cube centered at index. Accounts for the fact that each parent + def get_avg_mv(movement_vectors, index, edge_length): + ''' Computes the average movement vector offset from the movement vectors + in a cube centered at index. Accounts for the fact that each movement vector is a relative offset from its source location, not from index. Args: - parent_vectors (``np.array``): + movement_vectors (``np.array``): - A numpy array of parent vectors with dimensions + A numpy array of movement vectors with dimensions (channels, time, z, y, x). index (``gp.Coordinate``): A 4D coordiante (t, z, y, x) indicating the target - location to get the average parent vector for. + location to get the average movement vector for. edge_length (``int``): Length of each side of the cube within which the - parent vectors are averaged. + movement vectors are averaged. ''' radius = (edge_length - 1) // 2 - logger.debug("Getting average parent vectors with radius" + logger.debug("Getting average movement vectors with radius" " %d around index %s" % (radius, str(index))) offsets = [] - pv_shape = parent_vectors.shape + mv_shape = movement_vectors.shape # channels, t, z, y, x - assert(len(pv_shape) == 5) - pv_max_z = pv_shape[2] - pv_max_y = pv_shape[3] - pv_max_x = pv_shape[4] + assert(len(mv_shape) == 5) + mv_max_z = mv_shape[2] + mv_max_y = mv_shape[3] + mv_max_x = mv_shape[4] logger.debug("Type of index[1]: %s index[1] %s" % (str(type(index[1])), str(index[1]))) for z in range(max(0, index[1] - radius), - min(index[1] + radius + 1, pv_max_z)): + min(index[1] + radius + 1, mv_max_z)): for y in range(max(0, index[2] - radius), - min(index[2] + radius + 1, pv_max_y)): + min(index[2] + radius + 1, mv_max_y)): for x in range(max(0, index[3] - radius), - min(index[3] + radius + 1, pv_max_x)): + min(index[3] + radius + 1, mv_max_x)): c = gp.Coordinate((z, y, x)) c_with_time = gp.Coordinate((index[0], z, y, x)) relative_pos = c - index[1:] - offset_relative_to_c = parent_vectors[ + offset_relative_to_c = movement_vectors[ (Ellipsis,) + c_with_time] offsets.append(offset_relative_to_c + relative_pos) logger.debug("Offsets to average: %s" + str(offsets)) - parent_vector = tuple(float(sum(col) / len(col)) + movement_vector = tuple(float(sum(col) / len(col)) for col in zip(*offsets)) - return parent_vector + return movement_vector From 68b99001d86401ee438d07082b0c591f3ea85946 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 1 Jul 2022 13:11:36 -0400 Subject: [PATCH 199/263] cleanup config, prune to main method, cont. --- linajea/config/data.py | 6 +++--- linajea/config/evaluate.py | 13 ------------- linajea/config/extract.py | 4 ++-- linajea/config/solve.py | 8 ++++---- linajea/config/train.py | 6 +++--- linajea/config/train_test_validate_data.py | 1 + linajea/config/unet_config.py | 3 +++ linajea/config/utils.py | 21 +-------------------- 8 files changed, 17 insertions(+), 45 deletions(-) diff --git a/linajea/config/data.py b/linajea/config/data.py index 0ff4955..8f398d7 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -164,6 +164,6 @@ class DataSourceConfig: def __attrs_post_init__(self): assert (self.datafile is not None or - self.db_name is not None, - "please specify either a file source (datafile) " - "or a database source (db_name)") + self.db_name is not None), \ + ("please specify either a file source (datafile) " + "or a database source (db_name)") diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index b266de4..274cc9b 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -31,16 +31,6 @@ class EvaluateParametersTrackingConfig: window_size: int What is the maximum window size for which the fraction of error-free tracklets should be computed? - filter_polar_bodies: bool - Should polar bodies be removed from the computed tracks? - Requires cell state classifier predictions, removes objects with - a high polar body score from tracks, does not load GT polar - bodies. - filter_polar_bodies_key: str - If polar bodies should be filtered, which attribute in database - node collection should be used - filter_short_tracklets_len: int - If positive, remove all tracks shorter than this many objects ignore_one_off_div_errors: bool Division annotations are often slightly imprecise. Due to the limited temporal resolution the exact moment a division happens @@ -56,9 +46,6 @@ class EvaluateParametersTrackingConfig: roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) validation_score = attr.ib(type=bool, default=False) window_size = attr.ib(type=int, default=50) - filter_polar_bodies = attr.ib(type=bool, default=None) - filter_polar_bodies_key = attr.ib(type=str, default=None) - filter_short_tracklets_len = attr.ib(type=int, default=-1) ignore_one_off_div_errors = attr.ib(type=bool, default=False) fn_div_count_unconnected_parent = attr.ib(type=bool, default=True) diff --git a/linajea/config/extract.py b/linajea/config/extract.py index ae76d1e..3c72e24 100644 --- a/linajea/config/extract.py +++ b/linajea/config/extract.py @@ -42,7 +42,7 @@ class ExtractConfig: context: list of int Size of context by which block is grown, to ensure consistent solution along borders - use_pv_distance: bool + use_mv_distance: bool Use distance to location predicted by movement vector to look for closest neighbors, recommended """ @@ -52,4 +52,4 @@ class ExtractConfig: job = attr.ib(converter=ensure_cls(JobConfig), default=attr.Factory(JobConfig)) context = attr.ib(type=List[int], default=None) - use_pv_distance = attr.ib(type=bool, default=False) + use_mv_distance = attr.ib(type=bool, default=False) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 3beeaad..d9e7a6f 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -4,14 +4,14 @@ import itertools import logging import random -from typing import List +from typing import List, Tuple import attr from .data import DataROIConfig from .job import JobConfig from .utils import (ensure_cls, - ensure_cls_list + ensure_cls_list, load_config) logger = logging.getLogger(__name__) @@ -60,8 +60,8 @@ class SolveParametersConfig: weight_child = attr.ib(type=float, default=0.0) weight_continuation = attr.ib(type=float, default=0.0) weight_edge_score = attr.ib(type=float) - block_size = attr.ib(type=List[int]) - context = attr.ib(type=List[int]) + block_size = attr.ib(type=Tuple[int, int, int, int]) + context = attr.ib(type=Tuple[int, int, int, int]) max_cell_move = attr.ib(type=int, default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) feature_func = attr.ib(type=str, default="noop") diff --git a/linajea/config/train.py b/linajea/config/train.py index e4e23f4..64fd552 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -64,7 +64,7 @@ class _TrainConfig: cache_size = attr.ib(type=int) max_iterations = attr.ib(type=int) checkpoint_stride = attr.ib(type=int) -ap snapshot_stride = attr.ib(type=int) + snapshot_stride = attr.ib(type=int) profiling_stride = attr.ib(type=int) use_auto_mixed_precision = attr.ib(type=bool, default=False) use_swa = attr.ib(type=bool, default=False) @@ -120,7 +120,7 @@ class TrainTrackingConfig(_TrainConfig): move_radius = attr.ib(type=float) rasterize_radius = attr.ib(type=List[float]) augment = attr.ib(converter=ensure_cls(AugmentTrackingConfig)) - object_vectors_loss_transition_factor = attr.ib(type=float, default=0.01) - object_vectors_loss_transition_offset = attr.ib(type=int, default=20000) + movement_vectors_loss_transition_factor = attr.ib(type=float, default=0.01) + movement_vectors_loss_transition_offset = attr.ib(type=int, default=20000) use_radius = attr.ib(type=Dict[int, int], default=None, converter=use_radius_converter()) diff --git a/linajea/config/train_test_validate_data.py b/linajea/config/train_test_validate_data.py index c617364..2652c8a 100644 --- a/linajea/config/train_test_validate_data.py +++ b/linajea/config/train_test_validate_data.py @@ -89,6 +89,7 @@ def __attrs_post_init__(self): class TrainDataTrackingConfig(_DataConfig): """Defines a specialized class for the definition of a training data set """ + data_sources = attr.ib(converter=ensure_cls_list(DataSourceConfig)) @data_sources.validator def _check_train_data_source(self, attribute, value): """a train data source has to use datafiles and cannot have a database""" diff --git a/linajea/config/unet_config.py b/linajea/config/unet_config.py index 2724ff1..3e6ad77 100644 --- a/linajea/config/unet_config.py +++ b/linajea/config/unet_config.py @@ -62,6 +62,8 @@ class UnetConfig: Use very small weight for pixels lower than cutoff in gt map cell_indicator_cutoff: float Cutoff values for weight, in gt_cell_indicator map + train_only_cell_indicator: bool + Only train cell indicator network, not movement vectors Notes ----- @@ -107,3 +109,4 @@ class UnetConfig: num_fmaps = attr.ib(type=int) cell_indicator_weighted = attr.ib(type=bool, default=True) cell_indicator_cutoff = attr.ib(type=float, default=0.01) + train_only_cell_indicator = attr.ib(type=bool, default=False) diff --git a/linajea/config/utils.py b/linajea/config/utils.py index 6697f03..4e0b375 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -60,7 +60,7 @@ def dump_config(config): time.time())) logger.debug("config dump path: %s", path) with open(path, 'w') as f: - toml.dump(config, f) + toml.dump(config, f, encoder=toml.TomlNumpyEncoder()) return path @@ -189,26 +189,7 @@ def maybe_fix_config_paths_to_machine_and_load(config): config_dict["general"]["setup_dir"].replace( "/groups/funke/home/hirschp/linajea_experiments", paths["HOME"]) - if "model" in config_dict: - config_dict["model"]["path_to_script"] = \ - config_dict["model"]["path_to_script"].replace( - "/groups/funke/home/hirschp/linajea_experiments", - paths["HOME"]) - if "train" in config_dict: - config_dict["train"]["path_to_script"] = \ - config_dict["train"]["path_to_script"].replace( - "/groups/funke/home/hirschp/linajea_experiments", - paths["HOME"]) if "predict" in config_dict: - config_dict["predict"]["path_to_script"] = \ - config_dict["predict"]["path_to_script"].replace( - "/groups/funke/home/hirschp/linajea_experiments", - paths["HOME"]) - if "path_to_script_db_from_zarr" in config_dict["predict"]: - config_dict["predict"]["path_to_script_db_from_zarr"] = \ - config_dict["predict"]["path_to_script_db_from_zarr"].replace( - "/groups/funke/home/hirschp/linajea_experiments", - paths["HOME"]) if "output_zarr_dir" in config_dict["predict"]: config_dict["predict"]["output_zarr_dir"] = \ config_dict["predict"]["output_zarr_dir"].replace( From ea07decc4b2e318ec95ba8011ae4a4736cbef82c Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 1 Jul 2022 13:11:59 -0400 Subject: [PATCH 200/263] cleanup evaluation, prune to main method, cont. --- linajea/evaluation/__init__.py | 6 +- linajea/evaluation/analyze_results.py | 15 ++ linajea/evaluation/division_evaluation.py | 224 ---------------------- linajea/evaluation/report.py | 2 +- 4 files changed, 21 insertions(+), 226 deletions(-) delete mode 100644 linajea/evaluation/division_evaluation.py diff --git a/linajea/evaluation/__init__.py b/linajea/evaluation/__init__.py index 566a0dd..68d570b 100644 --- a/linajea/evaluation/__init__.py +++ b/linajea/evaluation/__init__.py @@ -2,4 +2,8 @@ from .match import match_edges from .evaluate_setup import evaluate_setup from .report import Report -from .analyze_results import get_results_sorted +from .analyze_results import (get_results_sorted, + get_best_result_config, + get_results_sorted_db, + get_result_id, + get_result_params) diff --git a/linajea/evaluation/analyze_results.py b/linajea/evaluation/analyze_results.py index 547e8b7..3e97789 100644 --- a/linajea/evaluation/analyze_results.py +++ b/linajea/evaluation/analyze_results.py @@ -1,3 +1,11 @@ +"""Provides a set of functions to get evaluation results from database + +get_results_sorted: get list of sorted results based on config +get_best_result_config: get best result based on config +get_results_sorted_db: get list of sorted results from given db +get_result_id: get result with given id +get_result_params: get result with given parameter values +""" import logging import pandas as pd @@ -37,6 +45,7 @@ def get_results_sorted(config, return get_results_sorted_db(db_name, config.general.db_host, + sparse=config.general.sparse, filter_params=filter_params, eval_params=config.evaluate.parameters, score_columns=score_columns, @@ -85,6 +94,7 @@ def get_best_result_config(config, def get_results_sorted_db(db_name, db_host, + sparse=True, filter_params=None, eval_params=None, score_columns=None, @@ -98,6 +108,9 @@ def get_results_sorted_db(db_name, Which database to use db_host: str Which database connection/host to use + sparse: bool + Is the ground truth sparse (not every instance is annotated) + If it is sparse, fp_edge errors are not included. filter_params: dict Has to be a valid mongodb query, used to filter results eval_params: EvaluateParametersConfig @@ -117,6 +130,8 @@ def get_results_sorted_db(db_name, if not score_columns: score_columns = ['fn_edges', 'identity_switches', 'fp_divisions', 'fn_divisions'] + if not sparse: + score_columns = ['fp_edges'] + score_columns if not score_weights: score_weights = [1.]*len(score_columns) diff --git a/linajea/evaluation/division_evaluation.py b/linajea/evaluation/division_evaluation.py deleted file mode 100644 index c55cd04..0000000 --- a/linajea/evaluation/division_evaluation.py +++ /dev/null @@ -1,224 +0,0 @@ -import scipy.spatial -from .match import match -import numpy as np -import logging - -logger = logging.getLogger(__name__) - - -def evaluate_divisions( - gt_divisions, - rec_divisions, - target_frame, - matching_threshold, - frame_buffer=0, - output_file=None): - ''' Full frame division evaluation - Arguments: - gt_divisions (dict: int -> list) - Dictionary from frame to [[z y x id], ...] - - rec_divisions (dict: int -> list) - Dictionary from frame to [[z y x id], ...] - - target_frame (int) - - matching_threshold (int) - - frame_buffer (int): - Number of adjacent frames that are also fully - annotated in gt_divisions. Default is 0. - - output_file (string): - If given, save the results to the file - - Output: - A set of division reports with TP, FP, FN, TN, accuracy, precision - and recall values for divisions in the target frame. Each report will - give a different amount of leniency in the time localization of the - division, from 0 to frame_buffer. For example, with a leniency of 1 - frame, GT divisions in the target frame can be matched to rec divisions - in +-1 frame when calculating FN, and rec divisions in the - target frame can be matched to GT divisions in +-1 frame when - calculating FP. Rec divisions in +-1 will NOT be counted as FPs however - (because we would need GT in +-2 to confirm if they are FP or TP), and - same for GT divisions in +-1 not counting as FNs. - - Note: if you have dense ground truth, it is better to do a global - hungarian matching with whatever leniency you want, and then calculate - a global report, not one focusing on a single frame. - - Algorithm: - Make a KD tree for each division point in each frame of GT and REC. - Match GT and REC divisions in target_frame using hungarian matching, - calculate report. - For unmatched GT divisions in target_frame, try matching to rec in +-1. - For unmatched REC divisions in target_frame, try matching to GT in +-1. - Calculate updated report. - Repeat last 2 lines until leniency reaches frame buffer. - ''' - gt_kd_trees = {} - gt_node_ids = {} - rec_kd_trees = {} - rec_node_ids = {} - - for b in range(0, frame_buffer + 1): - if b == 0: - ts = [target_frame] - else: - ts = [target_frame - b, target_frame + b] - rec_nodes = [] - gt_nodes = [] - rec_positions = [] - gt_positions = [] - for t in ts: - if t in rec_divisions: - # ids only - rec_nodes.extend([n[3] for n in rec_divisions[t]]) - # positions only - rec_positions.extend([n[0:3] for n in rec_divisions[t]]) - - if t in gt_divisions: - # ids only - gt_nodes.extend([n[3] for n in gt_divisions[t]]) - # positions only - gt_positions.extend([n[0:3] for n in gt_divisions[t]]) - - if len(gt_positions) > 0: - gt_kd_trees[b] = scipy.spatial.cKDTree(gt_positions) - else: - gt_kd_trees[b] = None - gt_node_ids[b] = gt_nodes - if len(rec_positions) > 0: - rec_kd_trees[b] = scipy.spatial.cKDTree(rec_positions) - else: - rec_kd_trees[b] = None - rec_node_ids[b] = rec_nodes - - matches = [] - gt_target_tree = gt_kd_trees[0] - rec_target_tree = rec_kd_trees[0] - logger.debug("Node Ids gt: %s", gt_node_ids) - logger.debug("Node Ids rec: %s", rec_node_ids) - reports = [] - for b in range(0, frame_buffer + 1): - if b == 0: - # Match GT and REC divisions in target_frame using hungarian - # matching, calculate report. - costs = construct_costs(gt_target_tree, gt_node_ids[0], - rec_target_tree, rec_node_ids[0], - matching_threshold) - if len(costs) == 0: - matches = [] - else: - matches, soln_cost = match(costs, - matching_threshold + 1) - logger.info("found %d matches in target frame" % len(matches)) - report = calculate_report(gt_node_ids, rec_node_ids, matches) - reports.append(report) - logger.info("report in target frame: %s", report) - else: - # For unmatched GT divisions in target_frame, - # try matching to rec in +-b. - matched_gt = [m[0] for m in matches] - gt_costs = construct_costs( - gt_target_tree, gt_node_ids[0], - rec_kd_trees[b], rec_node_ids[b], - matching_threshold, - exclude_gt=matched_gt) - if len(gt_costs) == 0: - gt_matches = [] - else: - gt_matches, soln_cost = match( - gt_costs, - matching_threshold + 1) - logger.info("Found %d gt matches in frames +-%d", - len(gt_matches), b) - matches.extend(gt_matches) - # For unmatched REC divisions in target_frame, - # try matching to GT in +-b. - matched_rec = [m[1] for m in matches] - rec_costs = construct_costs( - gt_kd_trees[b], gt_node_ids[b], - rec_target_tree, rec_node_ids[0], - matching_threshold, - exclude_rec=matched_rec) - if len(rec_costs) == 0: - rec_matches = [] - else: - rec_matches, soln_cost = match( - rec_costs, - matching_threshold + 1) - logger.info("Found %d rec matches in frames +-%d", - len(rec_matches), b) - matches.extend(rec_matches) - - # Calculate updated report. - report = calculate_report(gt_node_ids, rec_node_ids, matches) - reports.append(report) - logger.info("report +-%d: %s", b, report) - if output_file: - save_results_to_file(reports, output_file) - return reports - - -def construct_costs( - gt_tree, gt_nodes, - rec_tree, rec_nodes, - matching_threshold, - exclude_gt=[], - exclude_rec=[]): - costs = {} - if gt_tree is None or rec_tree is None: - return costs - neighbors = gt_tree.query_ball_tree(rec_tree, matching_threshold) - for i, js in enumerate(neighbors): - gt_node = gt_nodes[i] - if gt_node in exclude_gt: - continue - for j in js: - rec_node = rec_nodes[j] - if rec_node in exclude_rec: - continue - distance = np.linalg.norm( - np.array(gt_tree.data[i]) - - np.array(rec_tree.data[j])) - costs[(gt_node, rec_node)] = distance - return costs - - -def calculate_report( - gt_node_ids, - rec_node_ids, - matches): - matched_gt = [m[0] for m in matches] - matched_rec = [m[1] for m in matches] - - # gt_total, rec_total, FP, FN, Prec, Rec, F1 - gt_target_divs = gt_node_ids[0] - rec_target_divs = rec_node_ids[0] - gt_total = len(gt_target_divs) - rec_total = len(rec_target_divs) - fp_nodes = [n for n in rec_target_divs - if n not in matched_rec] - fp = len(fp_nodes) - fn_nodes = [n for n in gt_target_divs - if n not in matched_gt] - fn = len(fn_nodes) - prec = (rec_total - fp) / rec_total if rec_total > 0 else None - rec = (gt_total - fn) / gt_total if gt_total > 0 else None - f1 = (2 * prec * rec / (prec + rec) - if prec is not None and rec is not None and prec + rec > 0 - else None) - return (gt_total, rec_total, fp, fn, prec, rec, f1) - - -def save_results_to_file(reports, filename): - header = "frames, gt_total, rec_total, FP, FN, Prec, Rec, F1\n" - with open(filename, 'w') as f: - f.write(header) - for frames, report in enumerate(reports): - f.write(str(frames)) - f.write(", ") - f.write(", ".join(list(map(str, report)))) - f.write("\n") diff --git a/linajea/evaluation/report.py b/linajea/evaluation/report.py index 728bf97..335f01e 100644 --- a/linajea/evaluation/report.py +++ b/linajea/evaluation/report.py @@ -149,7 +149,7 @@ def set_fn_divisions( fn_divs_no_connections, fn_divs_unconnected_child, fn_divs_unconnected_parent, - tp_divs + tp_divs, fn_div_count_unconnected_parent): ''' Args: From 2b7c3719fb0296740a9f590f5f4ed564728d606b Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 1 Jul 2022 13:12:28 -0400 Subject: [PATCH 201/263] cleanup process_blockwise, prune to main method --- linajea/process_blockwise/__init__.py | 3 + .../daisy_check_functions.py | 5 + .../extract_edges_blockwise.py | 115 ++++++++++-------- .../process_blockwise/predict_blockwise.py | 40 +++--- linajea/process_blockwise/solve_blockwise.py | 34 ++++-- 5 files changed, 120 insertions(+), 77 deletions(-) diff --git a/linajea/process_blockwise/__init__.py b/linajea/process_blockwise/__init__.py index b3edd62..906daa0 100644 --- a/linajea/process_blockwise/__init__.py +++ b/linajea/process_blockwise/__init__.py @@ -1,3 +1,6 @@ +"""Provides a set of functions to compute the tracking results in a +block-wise manner +""" # flake8: noqa from .predict_blockwise import predict_blockwise from .extract_edges_blockwise import extract_edges_blockwise diff --git a/linajea/process_blockwise/daisy_check_functions.py b/linajea/process_blockwise/daisy_check_functions.py index 9ce5c46..77c566a 100644 --- a/linajea/process_blockwise/daisy_check_functions.py +++ b/linajea/process_blockwise/daisy_check_functions.py @@ -1,3 +1,8 @@ +"""Set of daisy block-wise processing utility functions + +check_function*: Check in database if block has already been processed +write_done*: Write into database that block has been processed +""" import pymongo def get_daisy_collection_name(step_name): diff --git a/linajea/process_blockwise/extract_edges_blockwise.py b/linajea/process_blockwise/extract_edges_blockwise.py index f689796..3079971 100644 --- a/linajea/process_blockwise/extract_edges_blockwise.py +++ b/linajea/process_blockwise/extract_edges_blockwise.py @@ -1,19 +1,29 @@ -from __future__ import absolute_import +"""Provides function to extract edges from object candidates block-wise +""" import logging import time -from scipy.spatial import cKDTree import numpy as np +from scipy.spatial import KDTree import daisy -import linajea from .daisy_check_functions import write_done, check_function +import linajea logger = logging.getLogger(__name__) def extract_edges_blockwise(linajea_config): + """Function to extract edges (compute edge candidates between + neighboring object candidates in adjacent frames) + Starts a number of worker processes to process the blocks. + + Args + ---- + linajea_config: TrackingConfig + Configuration object + """ data = linajea_config.inference_data.data_source extract_roi = daisy.Roi(offset=data.roi.offset, shape=data.roi.shape) @@ -115,80 +125,79 @@ def extract_edges_in_block( ( cell, np.array([attrs[d] for d in ['z', 'y', 'x']]), - np.array(attrs['parent_vector']) + np.array(attrs['movement_vector']) ) for cell, attrs in graph.nodes(data=True) if 't' in attrs and attrs['t'] == t ] - for t in range(t_begin - (2 if linajea_config.general.two_frame_edges else 1), + for t in range(t_begin - 1, t_end) } for t in range(t_begin, t_end): - for fd in [1,2] if linajea_config.general.two_frame_edges else [1]: - pre = t - fd - nex = t + pre = t - 1 + nex = t - logger.debug( - "Finding edges between cells in frames %d and %d " - "(%d and %d cells)", - pre, nex, len(cells_by_t[pre]), len(cells_by_t[nex])) + logger.debug( + "Finding edges between cells in frames %d and %d " + "(%d and %d cells)", + pre, nex, len(cells_by_t[pre]), len(cells_by_t[nex])) - if len(cells_by_t[pre]) == 0 or len(cells_by_t[nex]) == 0: + if len(cells_by_t[pre]) == 0 or len(cells_by_t[nex]) == 0: - logger.debug("There are no edges between these frames, skipping") - continue + logger.debug("There are no edges between these frames, skipping") + continue - # prepare KD tree for fast lookup of 'pre' cells - logger.debug("Preparing KD tree...") - all_pre_cells = cells_by_t[pre] - kd_data = [cell[1] for cell in all_pre_cells] - pre_kd_tree = cKDTree(kd_data) + # prepare KD tree for fast lookup of 'pre' cells + logger.debug("Preparing KD tree...") + all_pre_cells = cells_by_t[pre] + kd_data = [cell[1] for cell in all_pre_cells] + pre_kd_tree = KDTree(kd_data) - for th, val in linajea_config.extract.edge_move_threshold.items(): - if th == -1 or t < int(th): - edge_move_threshold = val - break + for th, val in linajea_config.extract.edge_move_threshold.items(): + if th == -1 or t < int(th): + edge_move_threshold = val + break - for i, nex_cell in enumerate(cells_by_t[nex]): + for i, nex_cell in enumerate(cells_by_t[nex]): - nex_cell_id = nex_cell[0] - nex_cell_center = nex_cell[1] - nex_parent_center = nex_cell_center + nex_cell[2] + nex_cell_id = nex_cell[0] + nex_cell_center = nex_cell[1] + nex_parent_center = nex_cell_center + nex_cell[2] - if linajea_config.extract.use_pv_distance: - pre_cells_indices = pre_kd_tree.query_ball_point( - nex_parent_center, - edge_move_threshold) - else: - pre_cells_indices = pre_kd_tree.query_ball_point( - nex_cell_center, - edge_move_threshold) - pre_cells = [all_pre_cells[i] for i in pre_cells_indices] + if linajea_config.extract.use_mv_distance: + pre_cells_indices = pre_kd_tree.query_ball_point( + nex_parent_center, + edge_move_threshold) + else: + pre_cells_indices = pre_kd_tree.query_ball_point( + nex_cell_center, + edge_move_threshold) + pre_cells = [all_pre_cells[i] for i in pre_cells_indices] - logger.debug( - "Linking to %d cells in previous frame", - len(pre_cells)) + logger.debug( + "Linking to %d cells in previous frame", + len(pre_cells)) - if len(pre_cells) == 0: - continue + if len(pre_cells) == 0: + continue - for pre_cell in pre_cells: + for pre_cell in pre_cells: - pre_cell_id = pre_cell[0] - pre_cell_center = pre_cell[1] + pre_cell_id = pre_cell[0] + pre_cell_center = pre_cell[1] - moved = (pre_cell_center - nex_cell_center) - distance = np.linalg.norm(moved) + moved = (pre_cell_center - nex_cell_center) + distance = np.linalg.norm(moved) - prediction_offset = (pre_cell_center - nex_parent_center) - prediction_distance = np.linalg.norm(prediction_offset) + prediction_offset = (pre_cell_center - nex_parent_center) + prediction_distance = np.linalg.norm(prediction_offset) - graph.add_edge( - nex_cell_id, pre_cell_id, - distance=distance, - prediction_distance=prediction_distance) + graph.add_edge( + nex_cell_id, pre_cell_id, + distance=distance, + prediction_distance=prediction_distance) logger.debug("Found %d edges", graph.number_of_edges()) diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index bc3eceb..b4c0010 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -1,12 +1,11 @@ -from __future__ import absolute_import +"""Provides function to predict object candidates block-wise +""" import json import logging import os import subprocess -import time import numpy as np -from numcodecs import Blosc import daisy from funlib.run import run @@ -18,6 +17,18 @@ def predict_blockwise(linajea_config): + """Function to predict object candidates using a previously + trained model. + + Starts a number of worker processes. Each process loads the model + and sets up the prediction pipeline and then repeatedly requests + blocks to process from this main process. + + Args + ---- + linajea_config: TrackingConfig + Configuration object + """ setup_dir = linajea_config.general.setup_dir data = linajea_config.inference_data.data_source @@ -55,13 +66,15 @@ def predict_blockwise(linajea_config): block_write_roi = daisy.Roi((0, 0, 0, 0), net_output_size) block_read_roi = block_write_roi.grow(context, context) - output_zarr = construct_zarr_filename(linajea_config, - data.datafile.filename, - linajea_config.inference_data.checkpoint) + output_zarr = construct_zarr_filename( + linajea_config, + data.datafile.filename, + linajea_config.inference_data.checkpoint) if linajea_config.predict.write_db_from_zarr: assert os.path.exists(output_zarr), \ - "{} does not exist, cannot write to db from it!".format(output_zarr) + "{} does not exist, cannot write to db from it!".format( + output_zarr) input_roi = output_roi block_read_roi = block_write_roi @@ -72,7 +85,6 @@ def predict_blockwise(linajea_config): maxima_ds = 'volumes/maxima' output_path = os.path.join(setup_dir, output_zarr) logger.info("Preparing zarr at %s" % output_path) - compressor = Blosc(cname='zstd', clevel=3, shuffle=Blosc.BITSHUFFLE) file_roi = daisy.Roi(offset=data.datafile.file_roi.offset, shape=data.datafile.file_roi.shape) @@ -83,8 +95,7 @@ def predict_blockwise(linajea_config): voxel_size, dtype=np.float32, write_size=net_output_size, - num_channels=3, - compressor_object=compressor) + num_channels=3) daisy.prepare_ds( output_path, cell_indicator_ds, @@ -92,8 +103,7 @@ def predict_blockwise(linajea_config): voxel_size, dtype=np.float32, write_size=net_output_size, - num_channels=1, - compressor_object=compressor) + num_channels=1) daisy.prepare_ds( output_path, maxima_ds, @@ -101,8 +111,7 @@ def predict_blockwise(linajea_config): voxel_size, dtype=np.float32, write_size=net_output_size, - num_channels=1, - compressor_object=compressor) + num_channels=1) logger.info("Following ROIs in world units:") logger.info("Input ROI = %s", input_roi) @@ -137,7 +146,8 @@ def predict_blockwise(linajea_config): block_read_roi, block_write_roi, process_function=lambda: predict_worker(linajea_config), - check_function=None if linajea_config.predict.no_db_access else lambda b: all([f(b) for f in cf]), + check_function=(None if linajea_config.predict.no_db_access + else lambda b: all([f(b) for f in cf])), num_workers=linajea_config.predict.job.num_workers, read_write_conflict=False, max_retries=0, diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index cea0b8f..0a0712e 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -1,5 +1,7 @@ +"""Provides function to solve an ILP with a predefined set of constraints +and a set of object and edge candidates. +""" import logging -import subprocess import time import daisy @@ -7,12 +9,32 @@ from .daisy_check_functions import ( check_function, write_done, check_function_all_blocks, write_done_all_blocks) -from linajea.tracking import track, nm_track, greedy_track +from linajea.tracking import track, greedy_track logger = logging.getLogger(__name__) def solve_blockwise(linajea_config): + """Function to solve an ILP-based optimization problem block-wise + + Notes + ----- + The set of constraints has been predefined. + For each block: + Takes the previously predicted object candidates and extracted edge + candidates, sets up the objective based on their score and creates + the respective constraints for all indicator variables and solve. + To achieve consistent solutions along the block boundary, compute + with overlap, blocks without overlap can be processed in parallel. + If there is overlap, compute these blocks sequentially; if a + solution for candidates in the overlap area has already been + computed by a previous block, enforce this in the remaining blocks. + + Args + ---- + linajea_config: TrackingConfig + Configuration object + """ parameters = linajea_config.solve.parameters # block_size/context are identical for all parameters block_size = daisy.Coordinate(parameters[0].block_size) @@ -93,7 +115,7 @@ def solve_blockwise(linajea_config): # Note: in the case of a set of parameters, # we are assuming that none of the individual parameters are # half done and only checking the hash for each block - check_function=None if linajea_config.solve.write_struct_svm else lambda b: check_function( + check_function=lambda b: check_function( b, step_name, db_name, @@ -201,16 +223,10 @@ def solve_in_block(linajea_config, if linajea_config.solve.greedy: greedy_track(graph=graph, selected_key=selected_keys[0], cell_indicator_threshold=0.2) - elif linajea_config.solve.non_minimal: - nm_track(graph, linajea_config, selected_keys, frames=frames) else: track(graph, linajea_config, selected_keys, frames=frames, block_id=block.block_id[1]) - if linajea_config.solve.write_struct_svm: - logger.info("wrote struct svm data, database not updated") - return 0 - start_time = time.time() graph.update_edge_attrs( roi=write_roi, From d84a20111934e190c817f2c34a0aa89f2a711229 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 1 Jul 2022 13:13:57 -0400 Subject: [PATCH 202/263] cleanup training, prune to main method --- linajea/prediction/predict.py | 72 +----------- linajea/training/torch_loss.py | 190 ++++++++++++++------------------ linajea/training/torch_model.py | 16 +++ linajea/training/train.py | 81 +------------- linajea/training/utils.py | 133 ++++++++++++++++------ 5 files changed, 210 insertions(+), 282 deletions(-) diff --git a/linajea/prediction/predict.py b/linajea/prediction/predict.py index 5794f94..e609a5a 100644 --- a/linajea/prediction/predict.py +++ b/linajea/prediction/predict.py @@ -18,15 +18,11 @@ from linajea.config import (load_config, TrackingConfig) -from linajea.gunpowder_nodes import (Clip, - NormalizeMinMax, - NormalizeMeanStd, - NormalizeMedianMad, - WriteCells) +from linajea.gunpowder_nodes import WriteCells from linajea.process_blockwise import write_done import linajea.training.torch_model from linajea.utils import construct_zarr_filename - +from linajea.training.utils import normalize logger = logging.getLogger(__name__) @@ -210,70 +206,6 @@ def predict(config): pipeline.request_batch(gp.BatchRequest()) -def normalize(file_source, config, raw, data_config=None): - """Add data normalization node to pipeline. - - Should be identical to the one used during training - - Notes - ----- - Which normalization method should be used? - None/default: - [0,1] based on data type - minmax: - normalize such that lower bound is at 0 and upper bound at 1 - clipping is less strict, some data might be outside of range - percminmax: - use precomputed percentile values for minmax normalization; - precomputed values are stored in data_config file that has to - be supplied; set perc_min/max to tag to be used - mean/median - normalize such that mean/median is at 0 and 1 std/mad is at -+1 - set perc_min/max tags for clipping beforehand - """ - if config.predict.normalization is None or \ - config.predict.normalization.type == 'default': - logger.info("default normalization") - file_source = file_source + \ - gp.Normalize( - raw, factor=1.0/np.iinfo(data_config['stats']['dtype']).max) - elif config.predict.normalization.type == 'minmax': - mn = config.predict.normalization.norm_bounds[0] - mx = config.predict.normalization.norm_bounds[1] - logger.info("minmax normalization %s %s", mn, mx) - file_source = file_source + \ - Clip(raw, mn=mn/2, mx=mx*2) + \ - NormalizeMinMax(raw, mn=mn, mx=mx, interpolatable=False) - elif config.predict.normalization.type == 'percminmax': - mn = data_config['stats'][config.predict.normalization.perc_min] - mx = data_config['stats'][config.predict.normalization.perc_max] - logger.info("perc minmax normalization %s %s", mn, mx) - file_source = file_source + \ - Clip(raw, mn=mn/2, mx=mx*2) + \ - NormalizeMinMax(raw, mn=mn, mx=mx) - elif config.predict.normalization.type == 'mean': - mean = data_config['stats']['mean'] - std = data_config['stats']['std'] - mn = data_config['stats'][config.predict.normalization.perc_min] - mx = data_config['stats'][config.predict.normalization.perc_max] - logger.info("mean normalization %s %s %s %s", mean, std, mn, mx) - file_source = file_source + \ - Clip(raw, mn=mn, mx=mx) + \ - NormalizeMeanStd(raw, mean=mean, std=std) - elif config.predict.normalization.type == 'median': - median = data_config['stats']['median'] - mad = data_config['stats']['mad'] - mn = data_config['stats'][config.predict.normalization.perc_min] - mx = data_config['stats'][config.predict.normalization.perc_max] - logger.info("median normalization %s %s %s %s", median, mad, mn, mx) - file_source = file_source + \ - Clip(raw, mn=mn, mx=mx) + \ - NormalizeMedianMad(raw, median=median, mad=mad) - else: - raise RuntimeError("invalid normalization method %s", - config.predict.normalization.type) - return file_source - if __name__ == "__main__": diff --git a/linajea/training/torch_loss.py b/linajea/training/torch_loss.py index 5709214..0746d0f 100644 --- a/linajea/training/torch_loss.py +++ b/linajea/training/torch_loss.py @@ -1,11 +1,14 @@ +"""Provides a wrapper class for the losses used in the tracking model +""" import torch -# from torchvision.utils import save_image -# import numpy as np from . import torch_model class LossWrapper(torch.nn.Module): + """Wraps a set of torch losses and tensorboard summaries used to train + the tracking model of Linajea + """ def __init__(self, config, current_step=0): super().__init__() self.config = config @@ -16,55 +19,42 @@ def __init__(self, config, current_step=0): torch.tensor(float(current_step)), requires_grad=False) def weighted_mse_loss(inputs, target, weight): - # print(((inputs - target) ** 2).size()) - # print((weight * ((inputs - target) ** 2)).size()) - # return (weight * ((inputs - target) ** 2)).mean() - # print((weight * ((inputs - target) ** 2)).sum(), weight.sum()) ws = weight.sum() * inputs.size()[0] / weight.size()[0] if abs(ws) <= 0.001: ws = 1 return (weight * ((inputs - target) ** 2)).sum()/ ws def weighted_mse_loss2(inputs, target, weight): - # print(((inputs - target) ** 2).size()) - # print((weight * ((inputs - target) ** 2)).size()) - # return (weight * ((inputs - target) ** 2)).mean() - # print((weight * ((inputs - target) ** 2)).sum(), weight.sum()) - # ws = weight.sum() - # if ws == 0: - # ws = 1 return (weight * ((inputs - target) ** 2)).mean() - # self.pv_loss = torch.nn.MSELoss(reduction='mean') - # self.ci_loss = torch.nn.MSELoss(reduction='mean') self.pv_loss = weighted_mse_loss self.ci_loss = weighted_mse_loss2 met_sum_intv = 10 loss_sum_intv = 1 self.summaries = { - "loss": [-1, loss_sum_intv], - "alpha": [-1, loss_sum_intv], - "cell_indicator_loss": [-1, loss_sum_intv], - "parent_vectors_loss_cell_mask": [-1, loss_sum_intv], - "parent_vectors_loss_maxima": [-1, loss_sum_intv], - "parent_vectors_loss": [-1, loss_sum_intv], - 'cell_ind_tpr_gt': [-1, met_sum_intv], - 'par_vec_cos_gt': [-1, met_sum_intv], - 'par_vec_diff_mn_gt': [-1, met_sum_intv], - 'par_vec_tpr_gt': [-1, met_sum_intv], - 'cell_ind_tpr_pred': [-1, met_sum_intv], - 'par_vec_cos_pred': [-1, met_sum_intv], - 'par_vec_diff_mn_pred': [-1, met_sum_intv], - 'par_vec_tpr_pred': [-1, met_sum_intv], + "loss": [-1, loss_sum_intv], + "alpha": [-1, loss_sum_intv], + "cell_indicator_loss": [-1, loss_sum_intv], + "movement_vectors_loss_cell_mask": [-1, loss_sum_intv], + "movement_vectors_loss_maxima": [-1, loss_sum_intv], + "movement_vectors_loss": [-1, loss_sum_intv], + 'cell_ind_tpr_gt': [-1, met_sum_intv], + 'par_vec_cos_gt': [-1, met_sum_intv], + 'par_vec_diff_mn_gt': [-1, met_sum_intv], + 'par_vec_tpr_gt': [-1, met_sum_intv], + 'cell_ind_tpr_pred': [-1, met_sum_intv], + 'par_vec_cos_pred': [-1, met_sum_intv], + 'par_vec_diff_mn_pred': [-1, met_sum_intv], + 'par_vec_tpr_pred': [-1, met_sum_intv], } def metric_summaries(self, gt_cell_center, cell_indicator, cell_indicator_cropped, - gt_parent_vectors_cropped, - parent_vectors_cropped, + gt_movement_vectors_cropped, + movement_vectors_cropped, maxima, maxima_in_cell_mask, output_shape_2): @@ -75,7 +65,6 @@ def metric_summaries(self, # predicted value at those locations tmp = cell_indicator[list(gt_max_loc.T)] - # tmp = tf.gather_nd(cell_indicator, gt_max_loc) # true positive if > 0.5 cell_ind_tpr_gt = torch.mean((tmp > 0.5).float()) @@ -91,27 +80,30 @@ def metric_summaries(self, # cropped ground truth cell locations gt_max_loc = torch.nonzero(gt_cell_center_cropped > 0.5) - # ground truth parent vectors at those locations - tmp_gt_par = gt_parent_vectors_cropped.permute(*tp_dims)[list(gt_max_loc.T)] + # ground truth movement vectors at those locations + tmp_gt_par = gt_movement_vectors_cropped.permute(*tp_dims)[ + list(gt_max_loc.T)] - # predicted parent vectors at those locations - tmp_par = parent_vectors_cropped.permute(*tp_dims)[list(gt_max_loc.T)] + # predicted movement vectors at those locations + tmp_par = movement_vectors_cropped.permute(*tp_dims)[ + list(gt_max_loc.T)] - # normalize predicted parent vectors + # normalize predicted movement vectors normalize_pred = torch.nn.functional.normalize(tmp_par, dim=1) - # normalize ground truth parent vectors + # normalize ground truth movement vectors normalize_gt = torch.nn.functional.normalize(tmp_gt_par, dim=1) - # cosine similarity predicted vs ground truth parent vectors + # cosine similarity predicted vs ground truth movement vectors cos_similarity = torch.sum(normalize_pred * normalize_gt, dim=1) # rate with cosine similarity > 0.9 par_vec_cos_gt = torch.mean((cos_similarity > 0.9).float()) - # distance between endpoints of predicted vs gt parent vectors + # distance between endpoints of predicted vs gt movement vectors par_vec_diff = torch.linalg.vector_norm( - (tmp_gt_par / self.voxel_size) - (tmp_par / self.voxel_size), dim=1) + (tmp_gt_par / self.voxel_size) - (tmp_par / self.voxel_size), + dim=1) # mean distance par_vec_diff_mn_gt = torch.mean(par_vec_diff) @@ -119,7 +111,6 @@ def metric_summaries(self, par_vec_tpr_gt = torch.mean((par_vec_diff < 1).float()) # predicted cell locations - # pred_max_loc = torch.nonzero(torch.reshape(maxima_in_cell_mask, output_shape_2)) pred_max_loc = torch.nonzero(torch.reshape( torch.gt(maxima * cell_indicator_cropped, 0.2), output_shape_2)) @@ -130,28 +121,31 @@ def metric_summaries(self, if not self.config.model.train_only_cell_indicator: tp_dims = [1, 2, 3, 4, 0] - # ground truth parent vectors at those locations - tmp_gt_par = gt_parent_vectors_cropped.permute(*tp_dims)[list(pred_max_loc.T)] + # ground truth movement vectors at those locations + tmp_gt_par = gt_movement_vectors_cropped.permute(*tp_dims)[ + list(pred_max_loc.T)] - # predicted parent vectors at those locations - tmp_par = parent_vectors_cropped.permute(*tp_dims)[list(pred_max_loc.T)] + # predicted movement vectors at those locations + tmp_par = movement_vectors_cropped.permute(*tp_dims)[ + list(pred_max_loc.T)] - # normalize predicted parent vectors + # normalize predicted movement vectors normalize_pred = torch.nn.functional.normalize(tmp_par, dim=1) - # normalize ground truth parent vectors + # normalize ground truth movement vectors normalize_gt = torch.nn.functional.normalize(tmp_gt_par, dim=1) - # cosine similarity predicted vs ground truth parent vectors + # cosine similarity predicted vs ground truth movement vectors cos_similarity = torch.sum(normalize_pred * normalize_gt, dim=1) # rate with cosine similarity > 0.9 par_vec_cos_pred = torch.mean((cos_similarity > 0.9).float()) - # distance between endpoints of predicted vs gt parent vectors + # distance between endpoints of predicted vs gt movement vectors par_vec_diff = torch.linalg.vector_norm( - (tmp_gt_par / self.voxel_size) - (tmp_par / self.voxel_size), dim=1) + (tmp_gt_par / self.voxel_size) - (tmp_par / self.voxel_size), + dim=1) # mean distance par_vec_diff_mn_pred = torch.mean(par_vec_diff) @@ -176,8 +170,8 @@ def forward(self, *, maxima, gt_cell_center, cell_mask=None, - gt_parent_vectors=None, - parent_vectors=None + gt_movement_vectors=None, + movement_vectors=None ): output_shape_1 = cell_indicator.size() @@ -199,48 +193,44 @@ def forward(self, *, # l=1, d', h', w' output_shape_2) - # print(maxima.size(), cell_indicator_cropped.size(), cell_indicator.size()) - # maxima = torch.eq(maxima, cell_indicator_cropped) - # l=1, d', h', w' - # maxima_in_cell_mask = torch.logical_and(maxima, cell_mask_cropped) maxima_in_cell_mask = maxima.float() * cell_mask_cropped.float() maxima_in_cell_mask = torch.reshape(maxima_in_cell_mask, (1,) + output_shape_2) # c=3, l=1, d', h', w' - parent_vectors_cropped = torch_model.crop( + movement_vectors_cropped = torch_model.crop( # c=3, l=1, d, h, w - parent_vectors, + movement_vectors, # c=3, l=1, d', h', w' (3,) + output_shape_2) # c=3, l=1, d', h', w' - gt_parent_vectors_cropped = torch_model.crop( + gt_movement_vectors_cropped = torch_model.crop( # c=3, l=1, d, h, w - gt_parent_vectors, + gt_movement_vectors, # c=3, l=1, d', h', w' (3,) + output_shape_2) - parent_vectors_loss_cell_mask = self.pv_loss( + movement_vectors_loss_cell_mask = self.pv_loss( # c=3, l=1, d, h, w - gt_parent_vectors, + gt_movement_vectors, # c=3, l=1, d, h, w - parent_vectors, + movement_vectors, # c=1, l=1, d, h, w (broadcastable) cell_mask) # cropped - parent_vectors_loss_maxima = self.pv_loss( + movement_vectors_loss_maxima = self.pv_loss( # c=3, l=1, d', h', w' - gt_parent_vectors_cropped, + gt_movement_vectors_cropped, # c=3, l=1, d', h', w' - parent_vectors_cropped, + movement_vectors_cropped, # c=1, l=1, d', h', w' (broadcastable) torch.reshape(maxima_in_cell_mask, (1,) + output_shape_2)) else: - parent_vectors_cropped = None - gt_parent_vectors_cropped = None + movement_vectors_cropped = None + gt_movement_vectors_cropped = None maxima_in_cell_mask = None # non-cropped @@ -248,18 +238,11 @@ def forward(self, *, if isinstance(self.config.model.cell_indicator_weighted, bool): self.config.model.cell_indicator_weighted = 0.00001 cond = gt_cell_indicator < self.config.model.cell_indicator_cutoff - weight = torch.where(cond, self.config.model.cell_indicator_weighted, 1.0) - # print(cond.size(), weight.size()) + weight = torch.where(cond, + self.config.model.cell_indicator_weighted, 1.0) else: weight = torch.tensor(1.0) - # print(torch.min(cell_indicator), torch.max(cell_indicator)) - # print(torch.min(gt_cell_indicator), torch.max(gt_cell_indicator)) - # for i, w in enumerate(torch.squeeze(weight, dim=0)): - # save_image(w, 'w_{}_{}.tif'.format(self.current_step, i)) - # for i, ci in enumerate(torch.squeeze(cell_indicator, dim=0)): - # save_image(ci, 'ci_{}_{}.tif'.format(self.current_step, i)) - # print(np.min(cell_indicator.detach().cpu().numpy()), - # np.max(cell_indicator.detach().cpu().numpy())) + cell_indicator_loss = self.ci_loss( # l=1, d, h, w gt_cell_indicator, @@ -269,40 +252,34 @@ def forward(self, *, weight) # cell_mask) - # print(torch.sum(gt_cell_indicator), torch.sum(cell_indicator), torch.sum(weight)) if self.config.model.train_only_cell_indicator: loss = cell_indicator_loss - parent_vectors_loss = 0 + movement_vectors_loss = 0 else: - if self.config.train.parent_vectors_loss_transition_offset: - # smooth transition from training parent vectors on complete cell mask to - # only on maxima + if self.config.train.movement_vectors_loss_transition_offset: + # smooth transition from training movement vectors on complete + # cell mask to only on maxima # https://www.wolframalpha.com/input/?i=1.0%2F(1.0+%2B+exp(0.01*(-x%2B20000)))+x%3D0+to+40000 alpha = (1.0 / (1.0 + torch.exp( - self.config.train.parent_vectors_loss_transition_factor * - (-self.current_step + self.config.train.parent_vectors_loss_transition_offset)))) + self.config.train.movement_vectors_loss_transition_factor * + (-self.current_step + + self.config.train.movement_vectors_loss_transition_offset)))) self.summaries['alpha'][0] = alpha - # multiply cell indicator loss with parent vector loss, since they have - # different magnitudes (this normalizes gradients by magnitude of other - # loss: (uv)' = u'v + uv') - # loss = 1000 * cell_indicator_loss + 0.1 * ( - # parent_vectors_loss_maxima*alpha + - # parent_vectors_loss_cell_mask*(1.0 - alpha) - # ) - parent_vectors_loss = (parent_vectors_loss_maxima * alpha + - parent_vectors_loss_cell_mask * (1.0 - alpha) - ) + movement_vectors_loss = ( + movement_vectors_loss_maxima * alpha + + movement_vectors_loss_cell_mask * (1.0 - alpha) + ) else: - # loss = 1000 * cell_indicator_loss + 0.1 * parent_vectors_loss_cell_mask - parent_vectors_loss = parent_vectors_loss_cell_mask + movement_vectors_loss = movement_vectors_loss_cell_mask - loss = cell_indicator_loss + parent_vectors_loss - self.summaries['parent_vectors_loss_cell_mask'][0] = parent_vectors_loss_cell_mask - self.summaries['parent_vectors_loss_maxima'][0] = parent_vectors_loss_maxima - self.summaries['parent_vectors_loss'][0] = parent_vectors_loss - # print(loss, cell_indicator_loss, parent_vectors_loss) + loss = cell_indicator_loss + movement_vectors_loss + self.summaries['movement_vectors_loss_cell_mask'][0] = \ + movement_vectors_loss_cell_mask + self.summaries['movement_vectors_loss_maxima'][0] = \ + movement_vectors_loss_maxima + self.summaries['movement_vectors_loss'][0] = movement_vectors_loss self.summaries['loss'][0] = loss self.summaries['cell_indicator_loss'][0] = cell_indicator_loss @@ -311,11 +288,12 @@ def forward(self, *, gt_cell_center, cell_indicator, cell_indicator_cropped, - gt_parent_vectors_cropped, - parent_vectors_cropped, + gt_movement_vectors_cropped, + movement_vectors_cropped, maxima, maxima_in_cell_mask, output_shape_2) self.current_step += 1 - return loss, cell_indicator_loss, parent_vectors_loss, self.summaries, torch.sum(cell_indicator_cropped) + return loss, cell_indicator_loss, movement_vectors_loss, \ + self.summaries, torch.sum(cell_indicator_cropped) diff --git a/linajea/training/torch_model.py b/linajea/training/torch_model.py index 8826e51..2921b3b 100644 --- a/linajea/training/torch_model.py +++ b/linajea/training/torch_model.py @@ -1,3 +1,5 @@ +"""Provides a U-Net based tracking model class using torch +""" import json import logging import os @@ -13,6 +15,20 @@ class UnetModelWrapper(torch.nn.Module): + """Wraps a torch U-Net implementation and extends it to the tracking + model used in Linajea + + Supports multiple styles of U-Nets: + - a single network for both cell indicator and movement vectors (single) + - two separate networks (split) + - a shared encoder and two decoders (multihead) + + Adds a layer to directly perform non maximum suppression using a 3d + pooling layer with stride 1 + + Input and Output shapes can be precomputed using `inout_shapes` (they + can differ as valid padding is used by default) + """ def __init__(self, config, current_step=0): super().__init__() self.config = config diff --git a/linajea/training/train.py b/linajea/training/train.py index 19c1afa..f8518af 100644 --- a/linajea/training/train.py +++ b/linajea/training/train.py @@ -20,19 +20,14 @@ import gunpowder as gp from linajea.gunpowder_nodes import (TracksSource, AddMovementVectors, - ShiftAugment, ShuffleChannels, Clip, - NoOp, NormalizeMinMax, NormalizeMeanStd, - NormalizeMedianMad, - RandomLocationExcludeTime) -from linajea.config import (load_config, - maybe_fix_config_paths_to_machine_and_load, - TrackingConfig) - + ShiftAugment, ShuffleChannels, + NoOp, RandomLocationExcludeTime) +from linajea.config import load_config from . import torch_model from . import torch_loss from .utils import (get_latest_checkpoint, - Cast) + normalize) logger = logging.getLogger(__name__) @@ -54,7 +49,7 @@ def train(config): """ # Get the latest checkpoint checkpoint_basename = os.path.join(config.general.setup_dir, 'train_net') - latest_checkpoint, trained_until = get_latest_checkpoint(checkpoint_basename) + _, trained_until = get_latest_checkpoint(checkpoint_basename) # training already done? if trained_until >= config.train.max_iterations: return @@ -405,7 +400,7 @@ def train(config): model=model, loss=loss, optimizer=opt, - checkpoint_basename=os.path.join(config.general.setup_dir, 'train_net'), + checkpoint_basename=checkpoint_basename, inputs=inputs, outputs=outputs, loss_inputs=loss_inputs, @@ -447,70 +442,6 @@ def train(config): i, time_of_iteration) -def normalize(file_source, config, raw, data_config=None): - """Add data normalization node to pipeline - - Notes - ----- - Which normalization method should be used? - None/default: - [0,1] based on data type - minmax: - normalize such that lower bound is at 0 and upper bound at 1 - clipping is less strict, some data might be outside of range - percminmax: - use precomputed percentile values for minmax normalization; - precomputed values are stored in data_config file that has to - be supplied; set perc_min/max to tag to be used - mean/median - normalize such that mean/median is at 0 and 1 std/mad is at -+1 - set perc_min/max tags for clipping beforehand - """ - if config.train.normalization is None or \ - config.train.normalization.type == 'default': - logger.info("default normalization") - file_source = file_source + \ - gp.Normalize(raw, - factor=1.0/np.iinfo(data_config['stats']['dtype']).max - if data_config is not None else None) - elif config.train.normalization.type == 'minmax': - mn = config.train.normalization.norm_bounds[0] - mx = config.train.normalization.norm_bounds[1] - logger.info("minmax normalization %s %s", mn, mx) - file_source = file_source + \ - Clip(raw, mn=mn/2, mx=mx*2) + \ - NormalizeMinMax(raw, mn=mn, mx=mx, interpolatable=False) - elif config.train.normalization.type == 'percminmax': - mn = data_config['stats'][config.train.normalization.perc_min] - mx = data_config['stats'][config.train.normalization.perc_max] - logger.info("perc minmax normalization %s %s", mn, mx) - file_source = file_source + \ - Clip(raw, mn=mn/2, mx=mx*2) + \ - NormalizeMinMax(raw, mn=mn, mx=mx) - elif config.train.normalization.type == 'mean': - mean = data_config['stats']['mean'] - std = data_config['stats']['std'] - mn = data_config['stats'][config.train.normalization.perc_min] - mx = data_config['stats'][config.train.normalization.perc_max] - logger.info("mean normalization %s %s %s %s", mean, std, mn, mx) - file_source = file_source + \ - Clip(raw, mn=mn, mx=mx) + \ - NormalizeMeanStd(raw, mean=mean, std=std) - elif config.train.normalization.type == 'median': - median = data_config['stats']['median'] - mad = data_config['stats']['mad'] - mn = data_config['stats'][config.train.normalization.perc_min] - mx = data_config['stats'][config.train.normalization.perc_max] - logger.info("median normalization %s %s %s %s", median, mad, mn, mx) - file_source = file_source + \ - Clip(raw, mn=mn, mx=mx) + \ - NormalizeMedianMad(raw, median=median, mad=mad) - else: - raise RuntimeError("invalid normalization method %s", - config.train.normalization.type) - return file_source - - def get_sources(config, raw, anchor, tracks, center_tracks, data_sources, val=False): """Create gunpowder source nodes for each data source in config diff --git a/linajea/training/utils.py b/linajea/training/utils.py index 0cd2c11..a7e5cf3 100644 --- a/linajea/training/utils.py +++ b/linajea/training/utils.py @@ -1,3 +1,5 @@ +"""Utility functions for training process +""" import copy import glob import math @@ -9,7 +11,24 @@ def get_latest_checkpoint(basename): + """Looks for the checkpoint with the highest step count + Checks for files name basename + '_checkpoint_*' + The suffix should be the iteration count + Selects the one with the highest one and returns the path to it + and the step count + + Args + ---- + basename: str + Path to and prefix of model checkpoints + + Returns + ------- + 2-tuple: str, int + Path to and iteration of latest checkpoint + + """ def atoi(text): return int(text) if text.isdigit() else text @@ -27,37 +46,6 @@ def natural_keys(text): return None, 0 -class Cast(gp.BatchFilter): - - def __init__( - self, - array, - dtype=np.float32): - - self.array = array - self.dtype = dtype - - def setup(self): - self.enable_autoskip() - array_spec = copy.deepcopy(self.spec[self.array]) - array_spec.dtype = self.dtype - self.updates(self.array, array_spec) - - def prepare(self, request): - deps = gp.BatchRequest() - deps[self.array] = request[self.array] - deps[self.array].dtype = None - return deps - - def process(self, batch, request): - if self.array not in batch.arrays: - return - - array = batch.arrays[self.array] - array.spec.dtype = self.dtype - array.data = array.data.astype(self.dtype) - - def crop(x, shape): '''Center-crop x to match spatial dimensions given by shape.''' @@ -134,3 +122,86 @@ def crop_to_factor(x, factor, kernel_sizes): return crop(x, target_spatial_shape) return x + + +def normalize(pipeline, config, raw, data_config=None): + """Add data normalization node to pipeline + + Args + ---- + pipeline: gp.BatchFilter + Gunpowder node/pipeline, typically a source node containing data + that should be normalized + config: TrackingConfig + Configuration object used to determine which type of + normalization should be performed + raw: gp.ArrayKey + Key identifying which array to normalize + data_config: dict of str: int, optional + Object containing statistics about data set that can be used + to normalize data + + Returns + ------- + gp.BatchFilter + Pipeline extended by a normalization node + + Notes + ----- + Which normalization method should be used? + None/default: + [0,1] based on data type + minmax: + normalize such that lower bound is at 0 and upper bound at 1 + clipping is less strict, some data might be outside of range + percminmax: + use precomputed percentile values for minmax normalization; + precomputed values are stored in data_config file that has to + be supplied; set perc_min/max to tag to be used + mean/median + normalize such that mean/median is at 0 and 1 std/mad is at -+1 + set perc_min/max tags for clipping beforehand + """ + if config.train.normalization is None or \ + config.train.normalization.type == 'default': + logger.info("default normalization") + pipeline = pipeline + \ + gp.Normalize(raw, + factor=1.0/np.iinfo(data_config['stats']['dtype']).max + if data_config is not None else None) + elif config.train.normalization.type == 'minmax': + mn = config.train.normalization.norm_bounds[0] + mx = config.train.normalization.norm_bounds[1] + logger.info("minmax normalization %s %s", mn, mx) + pipeline = pipeline + \ + Clip(raw, mn=mn/2, mx=mx*2) + \ + NormalizeLowerUpper(raw, lower=mn, upper=mx, interpolatable=False) + elif config.train.normalization.type == 'percminmax': + mn = data_config['stats'][config.train.normalization.perc_min] + mx = data_config['stats'][config.train.normalization.perc_max] + logger.info("perc minmax normalization %s %s", mn, mx) + pipeline = pipeline + \ + Clip(raw, mn=mn/2, mx=mx*2) + \ + NormalizeLowerUpper(raw, lower=mn, upper=mx) + elif config.train.normalization.type == 'mean': + mean = data_config['stats']['mean'] + std = data_config['stats']['std'] + mn = data_config['stats'][config.train.normalization.perc_min] + mx = data_config['stats'][config.train.normalization.perc_max] + logger.info("mean normalization %s %s %s %s", mean, std, mn, mx) + pipeline = pipeline + \ + Clip(raw, mn=mn, mx=mx) + \ + NormalizeAroundZero(raw, mapped_to_zero=mean, diff_mapped_to_one=std) + elif config.train.normalization.type == 'median': + median = data_config['stats']['median'] + mad = data_config['stats']['mad'] + mn = data_config['stats'][config.train.normalization.perc_min] + mx = data_config['stats'][config.train.normalization.perc_max] + logger.info("median normalization %s %s %s %s", median, mad, mn, mx) + pipeline = pipeline + \ + Clip(raw, mn=mn, mx=mx) + \ + NormalizeAroundZero(raw, mapped_to_zero=median, diff_mapped_to_one=mad) + else: + raise RuntimeError("invalid normalization method %s", + config.train.normalization.type) + return pipeline From 67535cc820b609cf6e1b622798884b67c592f800 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 1 Jul 2022 13:14:15 -0400 Subject: [PATCH 203/263] cleanup tracking, prune to main method --- linajea/tracking/__init__.py | 5 - linajea/tracking/greedy_track.py | 35 +- linajea/tracking/non_minimal_solver.py | 539 ------------------------ linajea/tracking/non_minimal_track.py | 95 ----- linajea/tracking/solver.py | 493 +--------------------- linajea/tracking/track.py | 38 +- linajea/tracking/track_graph.py | 41 +- linajea/tracking/tracking_parameters.py | 122 ------ 8 files changed, 85 insertions(+), 1283 deletions(-) delete mode 100644 linajea/tracking/non_minimal_solver.py delete mode 100644 linajea/tracking/non_minimal_track.py delete mode 100644 linajea/tracking/tracking_parameters.py diff --git a/linajea/tracking/__init__.py b/linajea/tracking/__init__.py index f263f99..4e13edc 100644 --- a/linajea/tracking/__init__.py +++ b/linajea/tracking/__init__.py @@ -1,10 +1,5 @@ # flake8: noqa -from __future__ import absolute_import -from .tracking_parameters import ( - TrackingParameters, NMTrackingParameters) from .track import track from .greedy_track import greedy_track -from .non_minimal_track import nm_track from .track_graph import TrackGraph from .solver import Solver -from .non_minimal_solver import NMSolver diff --git a/linajea/tracking/greedy_track.py b/linajea/tracking/greedy_track.py index 7ce10fe..b488c08 100644 --- a/linajea/tracking/greedy_track.py +++ b/linajea/tracking/greedy_track.py @@ -1,7 +1,15 @@ +"""Provides a function to compute the greedy tracking solution + +Greedily connects objects to closest neighbors as long as no constraints +are violated +""" import logging + import networkx as nx -from linajea.utils import CandidateDatabase + from daisy import Roi + +from linajea.utils import CandidateDatabase from .track_graph import TrackGraph logger = logging.getLogger(__name__) @@ -42,6 +50,31 @@ def greedy_track( frame_key='t', allow_new_tracks=True, roi=None): + """Computes greedy tracking solution + + Either directly takes graph or first loads graph from given db + + Args + ---- + graph: nx.DiGraph + Compute tracks from this graph, if None load graph from db + db_name: str + If graph not provided, load graph from this db + db_host: str + Host for database + selected_key: d + Edge attribute to use for storing results + cell_indicator_threshold: float + Discard node candidates with a lower score + metric: str + Which edge attribute to use to rank neighbors + frame_key: str + Which attribute defines what frame a node is in + allow_new_tracks: + Tracker can start new tracks (e.g. if no neighbors exist) + roi: + Restrict tracking to this ROI + """ if graph is None: cand_db = CandidateDatabase(db_name, db_host, 'r+') total_roi = cand_db.get_nodes_roi() diff --git a/linajea/tracking/non_minimal_solver.py b/linajea/tracking/non_minimal_solver.py deleted file mode 100644 index 524c3af..0000000 --- a/linajea/tracking/non_minimal_solver.py +++ /dev/null @@ -1,539 +0,0 @@ -# -*- coding: utf-8 -*- -import logging -import pylp - -logger = logging.getLogger(__name__) - - -class NMSolver(object): - ''' - Class for initializing and solving the ILP problem for - creating tracks from candidate nodes and edges using pylp. - This is the "non-minimal" (NM) version, or the original formulation before - we minimized the number of variables using assumptions about their - relationships - ''' - def __init__(self, track_graph, parameters, selected_key, frames=None, - check_node_close_to_roi=True, timeout=120, - add_node_density_constraints=False): - # frames: [start_frame, end_frame] where start_frame is inclusive - # and end_frame is exclusive. Defaults to track_graph.begin, - # track_graph.end - self.check_node_close_to_roi = check_node_close_to_roi - self.add_node_density_constraints = add_node_density_constraints - - self.graph = track_graph - self.parameters = parameters - self.selected_key = selected_key - self.start_frame = frames[0] if frames else self.graph.begin - self.end_frame = frames[1] if frames else self.graph.end - self.timeout = timeout - - self.node_selected = {} - self.edge_selected = {} - self.node_appear = {} - self.node_disappear = {} - self.node_split = {} - self.node_child = {} - self.node_continuation = {} - self.pinned_edges = {} - - if self.parameters.use_cell_state: - self.edge_split = {} - - self.num_vars = None - self.objective = None - self.main_constraints = [] # list of LinearConstraint objects - self.pin_constraints = [] # list of LinearConstraint objects - self.solver = None - - self._create_indicators() - self._create_solver() - self._create_constraints() - self.update_objective(parameters, selected_key) - - def update_objective(self, parameters, selected_key): - self.parameters = parameters - self.selected_key = selected_key - - self._set_objective() - self.solver.set_objective(self.objective) - - self.pinned_edges = {} - self.pin_constraints = [] - self._add_pin_constraints() - all_constraints = pylp.LinearConstraints() - for c in self.main_constraints + self.pin_constraints: - all_constraints.add(c) - self.solver.set_constraints(all_constraints) - - def _create_solver(self): - self.solver = pylp.LinearSolver( - self.num_vars, - pylp.VariableType.Binary, - preference=pylp.Preference.Any) - self.solver.set_num_threads(1) - self.solver.set_timeout(self.timeout) - - def solve(self): - solution, message = self.solver.solve() - logger.info(message) - logger.info("costs of solution: %f", solution.get_value()) - - for v in self.graph.nodes: - self.graph.nodes[v][self.selected_key] = solution[ - self.node_selected[v]] > 0.5 - - for e in self.graph.edges: - self.graph.edges[e][self.selected_key] = solution[ - self.edge_selected[e]] > 0.5 - - def _create_indicators(self): - - self.num_vars = 0 - - # four indicators per node: - # 1. selected - # 2. appear - # 3. disappear - # 4. split - for node in self.graph.nodes: - self.node_selected[node] = self.num_vars - self.node_appear[node] = self.num_vars + 1 - self.node_disappear[node] = self.num_vars + 2 - self.node_split[node] = self.num_vars + 3 - self.node_child[node] = self.num_vars + 4 - self.node_continuation[node] = self.num_vars + 5 - self.num_vars += 6 - - for edge in self.graph.edges(): - self.edge_selected[edge] = self.num_vars - self.num_vars += 1 - - if self.parameters.use_cell_state: - self.edge_split[edge] = self.num_vars - self.num_vars += 1 - - def _set_objective(self): - - logger.debug("setting objective") - - objective = pylp.LinearObjective(self.num_vars) - - # node selection and split costs - for node in self.graph.nodes: - objective.set_coefficient( - self.node_selected[node], - self._node_costs(node)) - objective.set_coefficient( - self.node_split[node], - self._split_costs(node)) - objective.set_coefficient( - self.node_child[node], - self._child_costs(node)) - objective.set_coefficient( - self.node_continuation[node], - self._continuation_costs(node)) - - # edge selection costs - for edge in self.graph.edges(): - objective.set_coefficient( - self.edge_selected[edge], - self._edge_costs(edge)) - - if self.parameters.use_cell_state: - objective.set_coefficient( - self.edge_split[edge], 0) - - # node appear (skip first frame) - for t in range(self.start_frame + 1, self.end_frame): - for node in self.graph.cells_by_frame(t): - objective.set_coefficient( - self.node_appear[node], - self.parameters.cost_appear) - for node in self.graph.cells_by_frame(self.start_frame): - objective.set_coefficient( - self.node_appear[node], - 0) - - # node disappear (skip last frame) - for t in range(self.start_frame, self.end_frame - 1): - for node in self.graph.cells_by_frame(t): - objective.set_coefficient( - self.node_disappear[node], - self.parameters.cost_disappear) - for node in self.graph.cells_by_frame(self.end_frame - 1): - objective.set_coefficient( - self.node_disappear[node], - 0) - - # remove node appear and disappear costs at edge of roi - if self.check_node_close_to_roi: - for node, data in self.graph.nodes(data=True): - if self._check_node_close_to_roi_edge( - node, - data, - self.parameters.max_cell_move): - objective.set_coefficient( - self.node_appear[node], - 0) - objective.set_coefficient( - self.node_disappear[node], - 0) - - self.objective = objective - - def _check_node_close_to_roi_edge(self, node, data, distance): - '''Return true if node is within distance to the z,y,x edge - of the roi. Assumes 4D data with t,z,y,x''' - if isinstance(distance, dict): - distance = min(distance.values()) - - begin = self.graph.roi.get_begin()[1:] - end = self.graph.roi.get_end()[1:] - for index, dim in enumerate(['z', 'y', 'x']): - node_dim = data[dim] - begin_dim = begin[index] - end_dim = end[index] - if node_dim + distance >= end_dim or\ - node_dim - distance < begin_dim: - logger.debug("Node %d with value %s in dimension %s " - "is within %s of range [%d, %d]" % - (node, node_dim, dim, distance, - begin_dim, end_dim)) - return True - logger.debug("Node %d with position [%s, %s, %s] is not within " - "%s to edge of roi %s" % - (node, data['z'], data['y'], data['x'], distance, - self.graph.roi)) - return False - - def _node_costs(self, node): - # node score times a weight plus a threshold - score_costs = ((self.parameters.threshold_node_score - - self.graph.nodes[node]['score']) * - self.parameters.weight_node_score) - - return score_costs - - def _split_costs(self, node): - if not self.parameters.use_cell_state: - return self.parameters.cost_split - elif self.parameters.use_cell_state == 'simple' or \ - self.parameters.use_cell_state == 'v1' or \ - self.parameters.use_cell_state == 'v2': - return ((self.parameters.threshold_split_score - - self.graph.nodes[node][self.parameters.prefix+'mother']) * - self.parameters.cost_split) - elif self.parameters.use_cell_state == 'v3' or \ - self.parameters.use_cell_state == 'v4': - if self.graph.nodes[node][self.parameters.prefix+'mother'] > \ - self.parameters.threshold_split_score: - return -self.parameters.cost_split - else: - return self.parameters.cost_split - else: - raise NotImplementedError("invalid value for use_cell_state") - - def _child_costs(self, node): - if not self.parameters.use_cell_state: - return 0 - elif self.parameters.use_cell_state == 'v1' or \ - self.parameters.use_cell_state == 'v2': - return ((self.parameters.threshold_split_score - - self.graph.nodes[node][self.parameters.prefix+'daughter']) * - self.parameters.cost_daughter) - elif self.parameters.use_cell_state == 'v3' or \ - self.parameters.use_cell_state == 'v4': - if self.graph.nodes[node][self.parameters.prefix+'daughter'] > \ - self.parameters.threshold_split_score: - return -self.parameters.cost_daughter - else: - return self.parameters.cost_daughter - else: - raise NotImplementedError("invalid value for use_cell_state") - - def _continuation_costs(self, node): - if not self.parameters.use_cell_state or \ - self.parameters.use_cell_state == 'v1' or \ - self.parameters.use_cell_state == 'v3': - return 0 - elif self.parameters.use_cell_state == 'v2': - return ((self.parameters.threshold_is_normal_score - - self.graph.nodes[node][self.parameters.prefix+'normal']) * - self.parameters.cost_normal) - elif self.parameters.use_cell_state == 'v4': - if self.graph.nodes[node][self.parameters.prefix+'normal'] > \ - self.parameters.threshold_is_normal_score: - # return 0 - return -self.parameters.cost_normal - else: - return self.parameters.cost_normal - else: - raise NotImplementedError("invalid value for use_cell_state") - - def _edge_costs(self, edge): - - # simple linear costs based on the score of an edge (negative if above - # threshold_edge_score, positive otherwise) - score_costs = 0 - - prediction_distance_costs = ( - (self.graph.edges[edge]['prediction_distance'] - - self.parameters.threshold_edge_score) * - self.parameters.weight_prediction_distance_cost) - - return score_costs + prediction_distance_costs - - def _create_constraints(self): - - self.main_constraints = [] - - self._add_edge_constraints() - for t in range(self.graph.begin, self.graph.end): - self._add_cell_cycle_constraints(t) - - for t in range(self.graph.begin, self.graph.end): - self._add_inter_frame_constraints(t) - - if self.add_node_density_constraints: - self._add_node_density_constraints_objective() - - - def _add_pin_constraints(self): - - for e in self.graph.edges(): - - if self.selected_key in self.graph.edges[e]: - - selected = self.graph.edges[e][self.selected_key] - self.pinned_edges[e] = selected - - ind_e = self.edge_selected[e] - constraint = pylp.LinearConstraint() - constraint.set_coefficient(ind_e, 1) - constraint.set_relation(pylp.Relation.Equal) - constraint.set_value(1 if selected else 0) - self.pin_constraints.append(constraint) - - def _add_edge_constraints(self): - - logger.debug("setting edge constraints") - - for e in self.graph.edges(): - - # if e is selected, u and v have to be selected - u, v = e - ind_e = self.edge_selected[e] - ind_u = self.node_selected[u] - ind_v = self.node_selected[v] - - constraint = pylp.LinearConstraint() - constraint.set_coefficient(ind_e, 2) - constraint.set_coefficient(ind_u, -1) - constraint.set_coefficient(ind_v, -1) - constraint.set_relation(pylp.Relation.LessEqual) - constraint.set_value(0) - self.main_constraints.append(constraint) - - logger.debug("set edge constraint %s", constraint) - - def _add_inter_frame_constraints(self, t): - '''Linking constraints from t to t+1.''' - - logger.debug("setting inter-frame constraints for frame %d", t) - - # Every selected node has exactly one selected edge to the previous and - # one or two to the next frame. This includes the special "appear" and - # "disappear" edges. - for node in self.graph.cells_by_frame(t): - # we model this as three constraints: - # sum(prev) - node = 0 # exactly one prev edge, - # iff node selected - # sum(next) - 2*node <= 0 # at most two next edges - # -sum(next) + node <= 0 # at least one next, iff node selected - - constraint_prev = pylp.LinearConstraint() - constraint_next_1 = pylp.LinearConstraint() - constraint_next_2 = pylp.LinearConstraint() - - # sum(prev) - - # all neighbors in previous frame - pinned_to_1 = [] - for edge in self.graph.prev_edges(node): - constraint_prev.set_coefficient(self.edge_selected[edge], 1) - if edge in self.pinned_edges and self.pinned_edges[edge]: - pinned_to_1.append(edge) - if len(pinned_to_1) > 1: - raise RuntimeError( - "Node %d has more than one prev edge pinned: %s" - % (node, pinned_to_1)) - # plus "appear" - constraint_prev.set_coefficient(self.node_appear[node], 1) - - # sum(next) - - for edge in self.graph.next_edges(node): - constraint_next_1.set_coefficient(self.edge_selected[edge], 1) - constraint_next_2.set_coefficient(self.edge_selected[edge], -1) - # plus "disappear" - constraint_next_1.set_coefficient(self.node_disappear[node], 1) - constraint_next_2.set_coefficient(self.node_disappear[node], -1) - # node - - constraint_prev.set_coefficient(self.node_selected[node], -1) - constraint_next_1.set_coefficient(self.node_selected[node], -2) - constraint_next_2.set_coefficient(self.node_selected[node], 1) - # relation, value - - constraint_prev.set_relation(pylp.Relation.Equal) - constraint_next_1.set_relation(pylp.Relation.LessEqual) - constraint_next_2.set_relation(pylp.Relation.LessEqual) - - constraint_prev.set_value(0) - constraint_next_1.set_value(0) - constraint_next_2.set_value(0) - - self.main_constraints.append(constraint_prev) - self.main_constraints.append(constraint_next_1) - self.main_constraints.append(constraint_next_2) - - logger.debug( - "set inter-frame constraints:\t%s\n\t%s\n\t%s", - constraint_prev, constraint_next_1, constraint_next_2) - - # Ensure that the split indicator is set for every cell that splits - # into two daughter cells. - for node in self.graph.cells_by_frame(t): - - # I.e., each node with two forwards edges is a split node. - - # Constraint 1 - # sum(forward edges) - split <= 1 - # sum(forward edges) > 1 => split == 1 - - # Constraint 2 - # sum(forward edges) - 2*split >= 0 - # sum(forward edges) <= 1 => split == 0 - - constraint_1 = pylp.LinearConstraint() - constraint_2 = pylp.LinearConstraint() - - # sum(forward edges) - for edge in self.graph.next_edges(node): - constraint_1.set_coefficient(self.edge_selected[edge], 1) - constraint_2.set_coefficient(self.edge_selected[edge], 1) - - # -[2*]split - constraint_1.set_coefficient(self.node_split[node], -1) - constraint_2.set_coefficient(self.node_split[node], -2) - - constraint_1.set_relation(pylp.Relation.LessEqual) - constraint_2.set_relation(pylp.Relation.GreaterEqual) - - constraint_1.set_value(1) - constraint_2.set_value(0) - - self.main_constraints.append(constraint_1) - self.main_constraints.append(constraint_2) - - logger.debug( - "set split-indicator constraints:\n\t%s\n\t%s", - constraint_1, constraint_2) - - def _add_cell_cycle_constraints(self, t): - for node in self.graph.cells_by_frame(t): - if self.parameters.use_cell_state: - # sum(next(edges_split))- 2*split >= 0 - constraint_3 = pylp.LinearConstraint() - for edge in self.graph.next_edges(node): - constraint_3.set_coefficient(self.edge_split[edge], 1) - constraint_3.set_coefficient(self.node_split[node], -2) - constraint_3.set_relation(pylp.Relation.Equal) - constraint_3.set_value(0) - - self.main_constraints.append(constraint_3) - - constraint_4 = pylp.LinearConstraint() - for edge in self.graph.prev_edges(node): - constraint_4.set_coefficient(self.edge_split[edge], 1) - constraint_4.set_coefficient(self.node_child[node], -1) - constraint_4.set_relation(pylp.Relation.Equal) - constraint_4.set_value(0) - - self.main_constraints.append(constraint_4) - - if self.parameters.use_cell_state == 'v2' or \ - self.parameters.use_cell_state == 'v4': - constraint_6 = pylp.LinearConstraint() - constraint_6.set_coefficient(self.node_selected[node], -1) - constraint_6.set_coefficient(self.node_split[node], 1) - constraint_6.set_coefficient(self.node_child[node], 1) - constraint_6.set_coefficient(self.node_continuation[node], 1) - constraint_6.set_relation(pylp.Relation.Equal) - constraint_6.set_value(0) - self.main_constraints.append(constraint_6) - - def _add_node_density_constraints_objective(self): - from scipy.spatial import cKDTree - import numpy as np - try: - nodes_by_t = { - t: [ - ( - node, - np.array([data[d] for d in ['z', 'y', 'x']]), - ) - for node, data in self.graph.nodes(data=True) - if 't' in data and data['t'] == t - ] - for t in range(self.start_frame, self.end_frame) - } - except: - for node, data in self.graph.nodes(data=True): - print(node, data) - raise - - rad = 15 - dia = 2*rad - filter_sz = 1*dia - r = filter_sz/2 - if isinstance(self.add_node_density_constraints, dict): - radius = self.add_node_density_constraints - else: - radius = {30: 35, 60: 25, 100: 15, 1000:10} - for t in range(self.start_frame, self.end_frame): - kd_data = [pos for _, pos in nodes_by_t[t]] - kd_tree = cKDTree(kd_data) - - if isinstance(radius, dict): - for th in sorted(list(radius.keys())): - if t < int(th): - r = radius[th] - break - nn_nodes = kd_tree.query_ball_point(kd_data, r, p=np.inf, - return_length=False) - - for idx, (node, _) in enumerate(nodes_by_t[t]): - if len(nn_nodes[idx]) == 1: - continue - constraint = pylp.LinearConstraint() - logger.debug("new constraint (frame %s) node pos %s", - t, kd_data[idx]) - for nn_id in nn_nodes[idx]: - if nn_id == idx: - continue - nn = nodes_by_t[t][nn_id][0] - constraint.set_coefficient(self.node_selected[nn], 1) - logger.debug( - "neighbor pos %s %s (node %s)", - kd_data[nn_id], - np.linalg.norm(np.array(kd_data[idx]) - - np.array(kd_data[nn_id]), - ), - nn) - constraint.set_coefficient(self.node_selected[node], 1) - constraint.set_relation(pylp.Relation.LessEqual) - constraint.set_value(1) - self.main_constraints.append(constraint) diff --git a/linajea/tracking/non_minimal_track.py b/linajea/tracking/non_minimal_track.py deleted file mode 100644 index b49d96a..0000000 --- a/linajea/tracking/non_minimal_track.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import absolute_import -from .non_minimal_solver import NMSolver -from .track_graph import TrackGraph -import logging -import time - -logger = logging.getLogger(__name__) - - -def nm_track(graph, config, selected_key, frame_key='t', frames=None): - ''' A wrapper function that takes a daisy subgraph and input parameters, - creates and solves the ILP to create tracks, and updates the daisy subgraph - to reflect the selected nodes and edges. - - Args: - - graph (``daisy.SharedSubgraph``): - - The candidate graph to extract tracks from - - config (``TrackingConfig``) - - Configuration object to be used. The parameters to use when - optimizing the tracking ILP are at config.solve.parameters - (can also be a list of parameters). - - selected_key (``string``) - - The key used to store the `true` or `false` selection status of - each node and edge in graph. Can also be a list of keys - corresponding to the list of parameters. - - frame_key (``string``, optional): - - The name of the node attribute that corresponds to the frame of the - node. Defaults to "t". - - frames (``list`` of ``int``): - - The start and end frames to solve in (in case the graph doesn't - have nodes in all frames). Start is inclusive, end is exclusive. - Defaults to graph.begin, graph.end - - ''' - # assuming graph is a daisy subgraph - if graph.number_of_nodes() == 0: - return - - use_cell_state = [p.use_cell_state + "mother" - if p.use_cell_state is not None - else None - for p in config.solve.parameters] - if any(use_cell_state): - assert None not in use_cell_state, \ - ("mixture of with and without use_cell_state in concurrent " - "solving not supported yet") - - parameters = config.solve.parameters - if not isinstance(selected_key, list): - selected_key = [selected_key] - - assert len(parameters) == len(selected_key),\ - "%d parameter sets and %d selected keys" %\ - (len(parameters), len(selected_key)) - - logger.debug("Creating track graph...") - track_graph = TrackGraph(graph_data=graph, - frame_key=frame_key, - roi=graph.roi) - - logger.info("Creating solver...") - solver = None - total_solve_time = 0 - for parameter, key in zip(parameters, selected_key): - if not solver: - solver = NMSolver( - track_graph, parameter, key, frames=frames, - check_node_close_to_roi=config.solve.check_node_close_to_roi, - timeout=config.solve.timeout, - add_node_density_constraints=config.solve.add_node_density_constraints) - else: - solver.update_objective(parameter, key) - - logger.debug("Solving for key %s", str(key)) - start_time = time.time() - solver.solve() - end_time = time.time() - total_solve_time += end_time - start_time - logger.info("Solving ILP took %s seconds", str(end_time - start_time)) - - for u, v, data in graph.edges(data=True): - if (u, v) in track_graph.edges: - data[key] = track_graph.edges[(u, v)][key] - logger.info("Solving ILP for all parameters took %s seconds", - str(total_solve_time)) diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index 065ee56..ee01b6d 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -1,7 +1,7 @@ +"""Provides a solver object encapsulating the ILP solver +""" # -*- coding: utf-8 -*- import logging -import os -import time import numpy as np @@ -14,33 +14,15 @@ class Solver(object): ''' Class for initializing and solving the ILP problem for creating tracks from candidate nodes and edges using pylp. - This is the "minimal" version, simplified to minimize the - number of hyperparamters ''' def __init__(self, track_graph, parameters, selected_key, frames=None, - write_struct_svm=False, block_id=None, - check_node_close_to_roi=True, timeout=120, - add_node_density_constraints=False): + block_id=None, check_node_close_to_roi=True, timeout=120): # frames: [start_frame, end_frame] where start_frame is inclusive # and end_frame is exclusive. Defaults to track_graph.begin, # track_graph.end - self.write_struct_svm = write_struct_svm - if self.write_struct_svm: - assert isinstance(self.write_struct_svm, str) - os.makedirs(self.write_struct_svm, exist_ok=True) self.check_node_close_to_roi = check_node_close_to_roi - self.add_node_density_constraints = add_node_density_constraints self.block_id = block_id - if parameters.feature_func == "noop": - self.feature_func = lambda x: x - elif parameters.feature_func == "log": - self.feature_func = np.log - elif parameters.feature_func == "square": - self.feature_func = np.square - else: - raise RuntimeError("invalid feature_func parameters %s", parameters.feature_func) - self.graph = track_graph self.start_frame = frames[0] if frames else self.graph.begin self.end_frame = frames[1] if frames else self.graph.end @@ -51,8 +33,6 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, self.node_appear = {} self.node_disappear = {} self.node_split = {} - self.node_child = {} - self.node_continuation = {} self.pinned_edges = {} self.num_vars = None @@ -61,9 +41,6 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, self.pin_constraints = [] # list of LinearConstraint objects self.solver = None - logger.debug("cell cycle key? %s", parameters.cell_cycle_key) - logger.debug("write ssvm? %s", self.write_struct_svm) - self._create_indicators() self._create_solver() self._create_constraints() @@ -114,57 +91,17 @@ def _create_indicators(self): # 2. appear # 3. disappear # 4. split - if self.write_struct_svm: - node_selected_file = open(f"{self.write_struct_svm}/node_selected_b{self.block_id}", 'w') - node_appear_file = open(f"{self.write_struct_svm}/node_appear_b{self.block_id}", 'w') - node_disappear_file = open(f"{self.write_struct_svm}/node_disappear_b{self.block_id}", 'w') - node_split_file = open(f"{self.write_struct_svm}/node_split_b{self.block_id}", 'w') - node_child_file = open(f"{self.write_struct_svm}/node_child_b{self.block_id}", 'w') - node_continuation_file = open(f"{self.write_struct_svm}/node_continuation_b{self.block_id}", 'w') - else: - node_selected_file = None - node_appear_file = None - node_disappear_file = None - node_split_file = None - node_child_file = None - node_continuation_file = None for node in self.graph.nodes: - if self.write_struct_svm: - node_selected_file.write("{} {}\n".format(node, self.num_vars)) - node_appear_file.write("{} {}\n".format(node, self.num_vars + 1)) - node_disappear_file.write("{} {}\n".format(node, self.num_vars + 2)) - node_split_file.write("{} {}\n".format(node, self.num_vars + 3)) - node_child_file.write("{} {}\n".format(node, self.num_vars + 4)) - node_continuation_file.write("{} {}\n".format(node, self.num_vars + 5)) - self.node_selected[node] = self.num_vars self.node_appear[node] = self.num_vars + 1 self.node_disappear[node] = self.num_vars + 2 self.node_split[node] = self.num_vars + 3 - self.node_child[node] = self.num_vars + 4 - self.node_continuation[node] = self.num_vars + 5 self.num_vars += 6 - if self.write_struct_svm: - node_selected_file.close() - node_appear_file.close() - node_disappear_file.close() - node_split_file.close() - node_child_file.close() - node_continuation_file.close() - - if self.write_struct_svm: - edge_selected_file = open(f"{self.write_struct_svm}/edge_selected_b{self.block_id}", 'w') for edge in self.graph.edges(): - if self.write_struct_svm: - edge_selected_file.write("{} {} {}\n".format(edge[0], edge[1], self.num_vars)) - self.edge_selected[edge] = self.num_vars self.num_vars += 1 - if self.write_struct_svm: - edge_selected_file.close() - def _set_objective(self): logger.debug("setting objective") @@ -172,91 +109,29 @@ def _set_objective(self): objective = pylp.LinearObjective(self.num_vars) # node selection and cell cycle costs - if self.write_struct_svm: - node_selected_weight_file = open( - f"{self.write_struct_svm}/features_node_selected_weight_b{self.block_id}", 'w') - node_selected_constant_file = open( - f"{self.write_struct_svm}/features_node_selected_constant_b{self.block_id}", 'w') - node_split_weight_file = open( - f"{self.write_struct_svm}/features_node_split_weight_b{self.block_id}", 'w') - node_split_constant_file = open( - f"{self.write_struct_svm}/features_node_split_constant_b{self.block_id}", 'w') - node_child_weight_or_constant_file = open( - f"{self.write_struct_svm}/features_node_child_weight_or_constant_b{self.block_id}", 'w') - node_continuation_weight_or_constant_file = open( - f"{self.write_struct_svm}/features_node_continuation_weight_or_constant_b{self.block_id}", 'w') - else: - node_selected_weight_file = None - node_selected_constant_file = None - node_split_weight_file = None - node_split_constant_file = None - node_child_weight_or_constant_file = None - node_continuation_weight_or_constant_file = None - for node in self.graph.nodes: objective.set_coefficient( self.node_selected[node], - self._node_costs(node, - node_selected_weight_file, - node_selected_constant_file)) - objective.set_coefficient( - self.node_split[node], - self._split_costs(node, - node_split_weight_file, - node_split_constant_file)) - objective.set_coefficient( - self.node_child[node], - self._child_costs( - node, - node_child_weight_or_constant_file)) + self._node_costs(node)) objective.set_coefficient( - self.node_continuation[node], - self._continuation_costs( - node, - node_continuation_weight_or_constant_file)) - - if self.write_struct_svm: - node_selected_weight_file.close() - node_selected_constant_file.close() - node_split_weight_file.close() - node_split_constant_file.close() - node_child_weight_or_constant_file.close() - node_continuation_weight_or_constant_file.close() + self.node_split[node], 1) # edge selection costs - if self.write_struct_svm: - edge_selected_weight_file = open( - f"{self.write_struct_svm}/features_edge_selected_weight_b{self.block_id}", 'w') - else: - edge_selected_weight_file = None - for edge in self.graph.edges(): objective.set_coefficient( self.edge_selected[edge], - self._edge_costs(edge, - edge_selected_weight_file)) - if self.write_struct_svm: - edge_selected_weight_file.close() + self._edge_costs(edge)) # node appear (skip first frame) - if self.write_struct_svm: - appear_file = open(f"{self.write_struct_svm}/features_node_appear_b{self.block_id}", 'w') - disappear_file = open(f"{self.write_struct_svm}/features_node_disappear_b{self.block_id}", 'w') for t in range(self.start_frame + 1, self.end_frame): for node in self.graph.cells_by_frame(t): objective.set_coefficient( self.node_appear[node], self.parameters.track_cost) - if self.write_struct_svm: - appear_file.write("{} 1\n".format(self.node_appear[node])) - disappear_file.write("{} 0\n".format(self.node_disappear[node])) for node in self.graph.cells_by_frame(self.start_frame): objective.set_coefficient( self.node_appear[node], 0) - if self.write_struct_svm: - appear_file.write("{} 0\n".format(self.node_appear[node])) - disappear_file.write("{} 0\n".format(self.node_disappear[node])) # remove node appear costs at edge of roi if self.check_node_close_to_roi: @@ -268,13 +143,6 @@ def _set_objective(self): objective.set_coefficient( self.node_appear[node], 0) - if self.write_struct_svm: - appear_file.write("{} 0\n".format( - self.node_appear[node])) - - if self.write_struct_svm: - appear_file.close() - disappear_file.close() self.objective = objective @@ -303,118 +171,22 @@ def _check_node_close_to_roi_edge(self, node, data, distance): self.graph.roi)) return False - def _node_costs(self, node, file_weight, file_constant): + def _node_costs(self, node): # node score times a weight plus a threshold - feature = self.graph.nodes[node]['score'] - if self.feature_func == np.log: - feature += 0.001 - feature = self.feature_func(feature) - score_costs = ((feature * + logger.info("%s %s %s", type(self.graph.nodes[node]['score']), + type(self.parameters.weight_node_score), + type(self.parameters.selection_constant)) + score_costs = ((self.graph.nodes[node]['score'] * self.parameters.weight_node_score) + self.parameters.selection_constant) - if self.write_struct_svm: - file_weight.write("{} {}\n".format( - self.node_selected[node], - feature)) - file_constant.write("{} 1\n".format(self.node_selected[node])) - return score_costs - def _split_costs(self, node, file_weight, file_constant): - # split score times a weight plus a threshold - if self.parameters.cell_cycle_key is None: - if self.write_struct_svm: - file_constant.write("{} 1\n".format(self.node_split[node])) - file_weight.write("{} 0\n".format(self.node_split[node])) - return 1 - feature = self.graph.nodes[node][self.parameters.cell_cycle_key+"mother"] - if self.feature_func == np.log: - feature += 0.001 - feature = self.feature_func(feature) - split_costs = ( - ( - # self.graph.nodes[node][self.parameters.cell_cycle_key][0] * - feature * - self.parameters.weight_division) + - self.parameters.division_constant) - - if self.write_struct_svm: - file_weight.write("{} {}\n".format( - self.node_split[node], - # self.graph.nodes[node][self.parameters.cell_cycle_key][0] - feature - )) - file_constant.write("{} 1\n".format(self.node_split[node])) - - return split_costs - - def _child_costs(self, node, file_weight_or_constant): - # split score times a weight - if self.parameters.cell_cycle_key is None: - if self.write_struct_svm: - file_weight_or_constant.write("{} 0\n".format( - self.node_child[node])) - return 0 - feature = self.graph.nodes[node][self.parameters.cell_cycle_key+"daughter"] - if self.feature_func == np.log: - feature += 0.001 - feature = self.feature_func(feature) - split_costs = ( - # self.graph.nodes[node][self.parameters.cell_cycle_key][1] * - feature * - self.parameters.weight_child) - - if self.write_struct_svm: - file_weight_or_constant.write("{} {}\n".format( - self.node_child[node], - # self.graph.nodes[node][self.parameters.cell_cycle_key][1] - feature - )) - - return split_costs - - def _continuation_costs(self, node, file_weight_or_constant): - # split score times a weight - if self.parameters.cell_cycle_key is None: - if self.write_struct_svm: - file_weight_or_constant.write("{} 0\n".format( - self.node_continuation[node])) - return 0 - feature = self.graph.nodes[node][self.parameters.cell_cycle_key+"normal"] - if self.feature_func == np.log: - feature += 0.001 - feature = self.feature_func(feature) - continuation_costs = ( - # self.graph.nodes[node][self.parameters.cell_cycle_key][2] * - feature * - self.parameters.weight_continuation) - - if self.write_struct_svm: - file_weight_or_constant.write("{} {}\n".format( - self.node_continuation[node], - # self.graph.nodes[node][self.parameters.cell_cycle_key][2] - feature - )) - - return continuation_costs - - def _edge_costs(self, edge, file_weight): + def _edge_costs(self, edge): # edge score times a weight - # TODO: normalize node and edge scores to a specific range and - # ordinality? - feature = self.graph.edges[edge]['prediction_distance'] - if self.feature_func == np.log: - feature += 0.001 - feature = self.feature_func(feature) - edge_costs = (feature * + edge_costs = (self.graph.edges[edge]['prediction_distance'] * self.parameters.weight_edge_score) - if self.write_struct_svm: - file_weight.write("{} {}\n".format( - self.edge_selected[edge], - feature)) - return edge_costs def _create_constraints(self): @@ -422,14 +194,10 @@ def _create_constraints(self): self.main_constraints = [] self._add_edge_constraints() - self._add_cell_cycle_constraints() for t in range(self.graph.begin, self.graph.end): self._add_inter_frame_constraints(t) - if self.add_node_density_constraints: - self._add_node_density_constraints_objective() - def _add_pin_constraints(self): @@ -451,9 +219,6 @@ def _add_edge_constraints(self): logger.debug("setting edge constraints") - if self.write_struct_svm: - edge_constraint_file = open(f"{self.write_struct_svm}/constraints_edge_b{self.block_id}", 'w') - cnstr = "2*{} -1*{} -1*{} <= 0\n" for e in self.graph.edges(): # if e is selected, u and v have to be selected @@ -470,14 +235,8 @@ def _add_edge_constraints(self): constraint.set_value(0) self.main_constraints.append(constraint) - if self.write_struct_svm: - edge_constraint_file.write(cnstr.format(ind_e, ind_u, ind_v)) - logger.debug("set edge constraint %s", constraint) - if self.write_struct_svm: - edge_constraint_file.close() - def _add_inter_frame_constraints(self, t): '''Linking constraints from t to t+1.''' @@ -486,8 +245,6 @@ def _add_inter_frame_constraints(self, t): # Every selected node has exactly one selected edge to the previous and # one or two to the next frame. This includes the special "appear" and # "disappear" edges. - if self.write_struct_svm: - node_edge_constraint_file = open(f"{self.write_struct_svm}/constraints_node_edge_b{self.block_id}", 'a') for node in self.graph.cells_by_frame(t): # we model this as three constraints: # sum(prev) - node = 0 # exactly one prev edge, @@ -499,19 +256,10 @@ def _add_inter_frame_constraints(self, t): constraint_next_1 = pylp.LinearConstraint() constraint_next_2 = pylp.LinearConstraint() - if self.write_struct_svm: - cnstr_prev = "" - cnstr_next_1 = "" - cnstr_next_2 = "" - - # sum(prev) - # all neighbors in previous frame pinned_to_1 = [] for edge in self.graph.prev_edges(node): constraint_prev.set_coefficient(self.edge_selected[edge], 1) - if self.write_struct_svm: - cnstr_prev += "1*{} ".format(self.edge_selected[edge]) if edge in self.pinned_edges and self.pinned_edges[edge]: pinned_to_1.append(edge) if len(pinned_to_1) > 1: @@ -520,70 +268,38 @@ def _add_inter_frame_constraints(self, t): % (node, pinned_to_1)) # plus "appear" constraint_prev.set_coefficient(self.node_appear[node], 1) - if self.write_struct_svm: - cnstr_prev += "1*{} ".format(self.node_appear[node]) - - # sum(next) for edge in self.graph.next_edges(node): constraint_next_1.set_coefficient(self.edge_selected[edge], 1) constraint_next_2.set_coefficient(self.edge_selected[edge], -1) - if self.write_struct_svm: - cnstr_next_1 += "1*{} ".format(self.edge_selected[edge]) - cnstr_next_2 += "-1*{} ".format(self.edge_selected[edge]) # plus "disappear" constraint_next_1.set_coefficient(self.node_disappear[node], 1) constraint_next_2.set_coefficient(self.node_disappear[node], -1) - if self.write_struct_svm: - cnstr_next_1 += "1*{} ".format(self.node_disappear[node]) - cnstr_next_2 += "-1*{} ".format(self.node_disappear[node]) # node constraint_prev.set_coefficient(self.node_selected[node], -1) constraint_next_1.set_coefficient(self.node_selected[node], -2) constraint_next_2.set_coefficient(self.node_selected[node], 1) - if self.write_struct_svm: - cnstr_prev += "-1*{} ".format(self.node_selected[node]) - cnstr_next_1 += "-2*{} ".format(self.node_selected[node]) - cnstr_next_2 += "1*{} ".format(self.node_selected[node]) # relation, value constraint_prev.set_relation(pylp.Relation.Equal) constraint_next_1.set_relation(pylp.Relation.LessEqual) constraint_next_2.set_relation(pylp.Relation.LessEqual) - if self.write_struct_svm: - cnstr_prev += " == " - cnstr_next_1 += " <= " - cnstr_next_2 += " <= " constraint_prev.set_value(0) constraint_next_1.set_value(0) constraint_next_2.set_value(0) - if self.write_struct_svm: - cnstr_prev += " 0\n" - cnstr_next_1 += " 0\n" - cnstr_next_2 += " 0\n" self.main_constraints.append(constraint_prev) self.main_constraints.append(constraint_next_1) self.main_constraints.append(constraint_next_2) - if self.write_struct_svm: - node_edge_constraint_file.write(cnstr_prev) - node_edge_constraint_file.write(cnstr_next_1) - node_edge_constraint_file.write(cnstr_next_2) - logger.debug( "set inter-frame constraints:\t%s\n\t%s\n\t%s", constraint_prev, constraint_next_1, constraint_next_2) - if self.write_struct_svm: - node_edge_constraint_file.close() - # Ensure that the split indicator is set for every cell that splits # into two daughter cells. - if self.write_struct_svm: - node_split_constraint_file = open(f"{self.write_struct_svm}/constraints_node_split_b{self.block_id}", 'a') for node in self.graph.cells_by_frame(t): # I.e., each node with two forwards edges is a split node. @@ -598,208 +314,25 @@ def _add_inter_frame_constraints(self, t): constraint_1 = pylp.LinearConstraint() constraint_2 = pylp.LinearConstraint() - if self.write_struct_svm: - cnstr_1 = "" - cnstr_2 = "" # sum(forward edges) for edge in self.graph.next_edges(node): constraint_1.set_coefficient(self.edge_selected[edge], 1) constraint_2.set_coefficient(self.edge_selected[edge], 1) - if self.write_struct_svm: - cnstr_1 += "1*{} ".format(self.edge_selected[edge]) - cnstr_2 += "1*{} ".format(self.edge_selected[edge]) # -[2*]split constraint_1.set_coefficient(self.node_split[node], -1) constraint_2.set_coefficient(self.node_split[node], -2) - if self.write_struct_svm: - cnstr_1 += "-1*{} ".format(self.node_split[node]) - cnstr_2 += "-2*{} ".format(self.node_split[node]) constraint_1.set_relation(pylp.Relation.LessEqual) constraint_2.set_relation(pylp.Relation.GreaterEqual) - if self.write_struct_svm: - cnstr_1 += " <= " - cnstr_2 += " >= " constraint_1.set_value(1) constraint_2.set_value(0) - if self.write_struct_svm: - cnstr_1 += " 1\n" - cnstr_2 += " 0\n" self.main_constraints.append(constraint_1) self.main_constraints.append(constraint_2) - if self.write_struct_svm: - node_split_constraint_file.write(cnstr_1) - node_split_constraint_file.write(cnstr_2) - logger.debug( "set split-indicator constraints:\n\t%s\n\t%s", constraint_1, constraint_2) - - if self.write_struct_svm: - node_split_constraint_file.close() - - def _add_cell_cycle_constraints(self): - # If an edge is selected, the division and child indicators are - # linked. Let e=(u,v) be an edge linking node u at time t + 1 to v in - # time t. - # Constraints: - # child(u) + selected(e) - split(v) <= 1 - # split(v) + selected(e) - child(u) <= 1 - - if self.write_struct_svm: - edge_split_constraint_file = open(f"{self.write_struct_svm}/constraints_edge_split_b{self.block_id}", 'a') - for e in self.graph.edges(): - - # if e is selected, u and v have to be selected - u, v = e - ind_e = self.edge_selected[e] - split_v = self.node_split[v] - child_u = self.node_child[u] - - link_constraint_1 = pylp.LinearConstraint() - link_constraint_1.set_coefficient(child_u, 1) - link_constraint_1.set_coefficient(ind_e, 1) - link_constraint_1.set_coefficient(split_v, -1) - link_constraint_1.set_relation(pylp.Relation.LessEqual) - link_constraint_1.set_value(1) - self.main_constraints.append(link_constraint_1) - if self.write_struct_svm: - link_cnstr_1 = "" - link_cnstr_1 += "1*{} ".format(child_u) - link_cnstr_1 += "1*{} ".format(ind_e) - link_cnstr_1 += "-1*{} ".format(split_v) - link_cnstr_1 += " <= " - link_cnstr_1 += " 1\n" - edge_split_constraint_file.write(link_cnstr_1) - - link_constraint_2 = pylp.LinearConstraint() - link_constraint_2.set_coefficient(split_v, 1) - link_constraint_2.set_coefficient(ind_e, 1) - link_constraint_2.set_coefficient(child_u, -1) - link_constraint_2.set_relation(pylp.Relation.LessEqual) - link_constraint_2.set_value(1) - self.main_constraints.append(link_constraint_2) - if self.write_struct_svm: - link_cnstr_2 = "" - link_cnstr_2 += "1*{} ".format(split_v) - link_cnstr_2 += "1*{} ".format(ind_e) - link_cnstr_2 += "-1*{} ".format(child_u) - link_cnstr_2 += " <= " - link_cnstr_2 += " 1\n" - edge_split_constraint_file.write(link_cnstr_2) - - if self.write_struct_svm: - edge_split_constraint_file.close() - - # Every selected node must be a split, child or continuation - # (exclusively). If a node is not selected, all the cell cycle - # indicators should be zero. - # Constraint for each node: - # split + child + continuation - selected = 0 - if self.write_struct_svm: - node_cell_cycle_constraint_file = open(f"{self.write_struct_svm}/constraints_node_cell_cycle_b{self.block_id}", 'a') - for node in self.graph.nodes(): - cycle_set_constraint = pylp.LinearConstraint() - cycle_set_constraint.set_coefficient(self.node_split[node], 1) - cycle_set_constraint.set_coefficient(self.node_child[node], 1) - cycle_set_constraint.set_coefficient(self.node_continuation[node], - 1) - cycle_set_constraint.set_coefficient(self.node_selected[node], -1) - cycle_set_constraint.set_relation(pylp.Relation.Equal) - cycle_set_constraint.set_value(0) - self.main_constraints.append(cycle_set_constraint) - if self.write_struct_svm: - cc_cnstr = "" - cc_cnstr += "1*{} ".format(self.node_split[node]) - cc_cnstr += "1*{} ".format(self.node_child[node]) - cc_cnstr += "1*{} ".format(self.node_continuation[node]) - cc_cnstr += "-1*{} ".format(self.node_selected[node]) - cc_cnstr += " == " - cc_cnstr += " 0\n" - node_cell_cycle_constraint_file.write(link_cnstr_2) - - if self.write_struct_svm: - node_cell_cycle_constraint_file.close() - - def _add_node_density_constraints_objective(self): - logger.debug("adding cell density constraints") - from scipy.spatial import cKDTree - import numpy as np - try: - nodes_by_t = { - t: [ - ( - node, - np.array([data[d] for d in ['z', 'y', 'x']]), - ) - for node, data in self.graph.nodes(data=True) - if 't' in data and data['t'] == t - ] - for t in range(self.start_frame, self.end_frame) - } - except: - for node, data in self.graph.nodes(data=True): - print(node, data) - raise - - rad = 15 - dia = 2*rad - filter_sz = 1*dia - r = filter_sz/2 - if isinstance(self.add_node_density_constraints, dict): - radius = self.add_node_density_constraints - else: - radius = {30: 35, 60: 25, 100: 15, 1000:10} - if self.write_struct_svm: - node_density_constraint_file = open(f"{self.write_struct_svm}/constraints_node_density_b{self.block_id}", 'w') - for t in range(self.start_frame, self.end_frame): - kd_data = [pos for _, pos in nodes_by_t[t]] - kd_tree = cKDTree(kd_data) - - if isinstance(radius, dict): - for th in sorted(list(radius.keys())): - if t < int(th): - r = radius[th] - break - nn_nodes = kd_tree.query_ball_point(kd_data, r, - return_length=False) - - for idx, (node, _) in enumerate(nodes_by_t[t]): - if len(nn_nodes[idx]) == 1: - continue - constraint = pylp.LinearConstraint() - if self.write_struct_svm: - cnstr = "" - logger.debug("new constraint (frame %s) node pos %s (node %s)", - t, kd_data[idx], node) - for nn_id in nn_nodes[idx]: - if nn_id == idx: - continue - nn = nodes_by_t[t][nn_id][0] - constraint.set_coefficient(self.node_selected[nn], 1) - if self.write_struct_svm: - cnstr += "1*{} ".format(self.node_selected[nn]) - logger.debug( - "neighbor pos %s %s (node %s)", - kd_data[nn_id], - np.linalg.norm(np.array(kd_data[idx]) - - np.array(kd_data[nn_id]), - ), - nn) - constraint.set_coefficient(self.node_selected[node], 1) - constraint.set_relation(pylp.Relation.LessEqual) - constraint.set_value(1) - self.main_constraints.append(constraint) - if self.write_struct_svm: - cnstr += "1*{} ".format(self.node_selected[node]) - cnstr += " <= " - cnstr += " 1\n" - node_density_constraint_file.write(cnstr) - - if self.write_struct_svm: - node_density_constraint_file.close() diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index cb22329..7fd3560 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -1,9 +1,12 @@ -from __future__ import absolute_import -from .solver import Solver -from .track_graph import TrackGraph +"""Provides a function to compute the tracking solution for multiple +parameter sets +""" import logging import time +from .solver import Solver +from .track_graph import TrackGraph + logger = logging.getLogger(__name__) @@ -47,28 +50,6 @@ def track(graph, config, selected_key, frame_key='t', frames=None, The ID of the current daisy block. ''' - # cell_cycle_keys = [p.cell_cycle_key for p in config.solve.parameters] - cell_cycle_keys = [p.cell_cycle_key + "mother" - if p.cell_cycle_key is not None - else None - for p in config.solve.parameters] - if any(cell_cycle_keys): - assert None not in cell_cycle_keys, \ - ("mixture of with and without cell_cycle key in concurrent " - "solving not supported yet") - # remove nodes that don't have a cell cycle key, with warning - to_remove = [] - for node, data in graph.nodes(data=True): - for key in cell_cycle_keys: - if key not in data: - raise RuntimeError( - "Node %d does not have cell cycle key %s", - node, key) - - for node in to_remove: - logger.debug("Removing node %d", node) - graph.remove_node(node) - # assuming graph is a daisy subgraph if graph.number_of_nodes() == 0: logger.info("No nodes in graph - skipping solving step") @@ -94,17 +75,12 @@ def track(graph, config, selected_key, frame_key='t', frames=None, if not solver: solver = Solver( track_graph, parameter, key, frames=frames, - write_struct_svm=config.solve.write_struct_svm, block_id=block_id, check_node_close_to_roi=config.solve.check_node_close_to_roi, - timeout=config.solve.timeout, - add_node_density_constraints=config.solve.add_node_density_constraints) + timeout=config.solve.timeout) else: solver.update_objective(parameter, key) - if config.solve.write_struct_svm: - logger.info("wrote struct svm data, skipping solving") - break logger.info("Solving for key %s", str(key)) start_time = time.time() solver.solve() diff --git a/linajea/tracking/track_graph.py b/linajea/tracking/track_graph.py index 5a4fa93..a829472 100644 --- a/linajea/tracking/track_graph.py +++ b/linajea/tracking/track_graph.py @@ -1,13 +1,41 @@ -import networkx as nx +"""Provides a specialized nx.DiGraph class to provide additional +functionality for a graph that represent tracks, e.g., to get all tracks, +all nodes in one frame. +""" import logging +import networkx as nx + logger = logging.getLogger(__name__) class TrackGraph(nx.DiGraph): '''A track graph of cells and inter-frame edges between them. - Args: + Constructor can take an existing networkx graph and set additional + attributes + + Attributes + ---------- + begin: int + end: int + Range of time frames contained in data + _cells_by_frame: dict of int: nodes + Maps from frames to all nodes in that frame + frame_key: str + The name of the node attribute that corresponds to the frame of + the node. Defaults to "t". + roi: daisy.Roi + The region of interest that the graph covers. Used for solving. + ''' + + def __init__( + self, + graph_data=None, + frame_key='t', + roi=None): + """ + Args: graph_data (optional): @@ -24,14 +52,7 @@ class TrackGraph(nx.DiGraph): The region of interest that the graph covers. Used for solving. - ''' - - def __init__( - self, - graph_data=None, - frame_key='t', - roi=None): - + """ super(TrackGraph, self).__init__(incoming_graph_data=graph_data) self.begin = None diff --git a/linajea/tracking/tracking_parameters.py b/linajea/tracking/tracking_parameters.py deleted file mode 100644 index 89367c7..0000000 --- a/linajea/tracking/tracking_parameters.py +++ /dev/null @@ -1,122 +0,0 @@ -class TrackingParameters(object): - - def __init__( - self, - block_size=None, - context=None, - track_cost=None, - max_cell_move=None, - selection_constant=None, - weight_node_score=None, - weight_edge_score=None, - division_constant=1, - weight_division=0, - weight_child=0, - weight_continuation=0, - version=None, - **kwargs): - - # block size and context - assert block_size is not None, "Failed to specify block_size" - self.block_size = block_size - assert context is not None, "Failed to specify context" - self.context = context - - # track costs: - assert track_cost is not None, "Failed to specify track_cost" - self.track_cost = track_cost - - # max_cell_move - # nodes within this distance to the block boundary will not pay - # the appear and disappear costs - # (Should be < 1/2 the context in z/x/y) - assert max_cell_move is not None, "Failed to specify max_cell_move" - self.max_cell_move = max_cell_move - - assert selection_constant is not None,\ - "Failed to specify selection_constant" - self.selection_constant = selection_constant - - # scaling factors - assert weight_node_score is not None,\ - "Failed to specify weight_node_score" - self.weight_node_score = weight_node_score - - assert weight_edge_score is not None,\ - "Failed to specify weight_edge_score" - self.weight_edge_score = weight_edge_score - - # Cell cycle - self.division_constant = division_constant - self.weight_division = weight_division - self.weight_child = weight_child - self.weight_continuation = weight_continuation - - # version control - self.version = version - - -class NMTrackingParameters(object): - - def __init__( - self, - block_size=None, - context=None, cost_appear=None, - cost_disappear=None, - cost_split=None, - max_cell_move=None, - threshold_node_score=None, - weight_node_score=None, - threshold_edge_score=None, - weight_prediction_distance_cost=None, - version=None, - **kwargs): - - # block size and context - assert block_size is not None, "Failed to specify block_size" - self.block_size = block_size - assert context is not None, "Failed to specify context" - self.context = context - - # track costs: - assert cost_appear is not None, "Failed to specify cost_appear" - self.cost_appear = cost_appear - assert cost_disappear is not None, "Failed to specify cost_disappear" - self.cost_disappear = cost_disappear - assert cost_split is not None, "Failed to specify cost_split" - self.cost_split = cost_split - - # max_cell_move - # nodes within this distance to the block boundary will not pay - # the appear and disappear costs - # (Should be < 1/2 the context in z/x/y) - assert max_cell_move is not None, "Failed to specify max_cell_move" - self.max_cell_move = max_cell_move - - # node costs: - - # nodes with scores below this threshold will have a positive cost, - # above this threshold a negative cost - assert threshold_node_score is not None,\ - "Failed to specify threshold_node_score" - self.threshold_node_score = threshold_node_score - - # scaling factor after the conversion to costs above - assert weight_node_score is not None,\ - "Failed to specify weight_node_score" - self.weight_node_score = weight_node_score - - # edge costs: - - # similar to node costs, determines when a cost is positive/negative - assert threshold_edge_score is not None,\ - "Failed to specify threshold_edge_score" - self.threshold_edge_score = threshold_edge_score - - # how to weigh the Euclidean distance between the predicted position - # and the actual position of cells for the costs of an edge - assert weight_prediction_distance_cost is not None,\ - "Failed to specify weight_prediction_distance_cost" - self.weight_prediction_distance_cost = weight_prediction_distance_cost - # version control - self.version = version From f66f7ca43e28e0ab99c822fd8f5b3536f2e9ca61 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 1 Jul 2022 13:14:29 -0400 Subject: [PATCH 204/263] cleanup utils, prune to main method --- linajea/utils/__init__.py | 1 - linajea/utils/candidate_database.py | 9 ++- linajea/utils/check_or_create_db.py | 36 ++++++++-- linajea/utils/construct_zarr_filename.py | 4 +- linajea/utils/get_next_inference_data.py | 91 +++++++++++++++++------- linajea/utils/parse_tracks_file.py | 27 ++++--- linajea/utils/print_time.py | 2 + linajea/utils/write_search_files.py | 11 +-- 8 files changed, 128 insertions(+), 53 deletions(-) diff --git a/linajea/utils/__init__.py b/linajea/utils/__init__.py index f79b26d..75dbcdd 100644 --- a/linajea/utils/__init__.py +++ b/linajea/utils/__init__.py @@ -1,5 +1,4 @@ # flake8: noqa -from __future__ import absolute_import from .candidate_database import CandidateDatabase from .print_time import print_time from .parse_tracks_file import parse_tracks_file diff --git a/linajea/utils/candidate_database.py b/linajea/utils/candidate_database.py index be0927e..334b189 100644 --- a/linajea/utils/candidate_database.py +++ b/linajea/utils/candidate_database.py @@ -1,8 +1,15 @@ -from daisy.persistence import MongoDbGraphProvider +"""Provides a class wrapping a mongodb database + +The database is used to store object and edge candidates, sets of +parameters that have been used to compute tracks from these and the +associated solutions and evaluation. +""" import logging import pymongo import time + import numpy as np +from daisy.persistence import MongoDbGraphProvider from daisy import Coordinate, Roi logger = logging.getLogger(__name__) diff --git a/linajea/utils/check_or_create_db.py b/linajea/utils/check_or_create_db.py index 534eb56..b25e032 100644 --- a/linajea/utils/check_or_create_db.py +++ b/linajea/utils/check_or_create_db.py @@ -1,3 +1,8 @@ +"""Provides function to check if database for given setup exists already. + +If yes, returns name. +If no, creates it or throws error (depending on flag) +""" import datetime import logging import os @@ -10,6 +15,29 @@ def checkOrCreateDB(db_host, setup_dir, sample, checkpoint, cell_score_threshold, roi=None, prefix="linajea_", tag=None, create_if_not_found=True): + """ + + Attributes + ---------- + db_host: str + Host address of mongodb server + setup_dir: str + Which experiment/setup to use + sample: str + Which data sample are we looking for? + checkpoint: int + Which model checkpoint was used for prediction? + cell_score_threshold: + Which score threshold was used for prediction? + roi: dict of str: List[int] + What ROI was predicted? + prefix: str + Prefix used for database names + tag: str, optional + Optional tag used to mark experiment + create_if_not_found: bool + Should db be created if not found? (otherwise exception is thrown) + """ db_host = db_host info = {} @@ -21,12 +49,12 @@ def checkOrCreateDB(db_host, setup_dir, sample, checkpoint, if tag is not None: info["tag"] = tag - return checkOrCreateDBMeta(db_host, info, prefix=prefix, - create_if_not_found=create_if_not_found) + return _checkOrCreateDBMeta(db_host, info, prefix=prefix, + create_if_not_found=create_if_not_found) -def checkOrCreateDBMeta(db_host, db_meta_info, prefix="linajea_", - create_if_not_found=True): +def _checkOrCreateDBMeta(db_host, db_meta_info, prefix="linajea_", + create_if_not_found=True): db_meta_info_no_roi = {k: v for k, v in db_meta_info.items() if k != "roi"} client = pymongo.MongoClient(host=db_host) diff --git a/linajea/utils/construct_zarr_filename.py b/linajea/utils/construct_zarr_filename.py index 6962ded..cb83953 100644 --- a/linajea/utils/construct_zarr_filename.py +++ b/linajea/utils/construct_zarr_filename.py @@ -1,9 +1,11 @@ +"""Provide simple function to create standard filename for zarr prediction file +""" import os def construct_zarr_filename(config, sample, checkpoint): return os.path.join( - config.predict.output_zarr_prefix, + config.predict.output_zarr_dir, config.general.setup, os.path.basename(sample) + 'predictions' + (config.general.tag if config.general.tag is not None else "") + diff --git a/linajea/utils/get_next_inference_data.py b/linajea/utils/get_next_inference_data.py index f5e8435..1a3d1cc 100644 --- a/linajea/utils/get_next_inference_data.py +++ b/linajea/utils/get_next_inference_data.py @@ -1,3 +1,10 @@ +"""Provides facility to automatically loop over all desired samples + +Useful if data set (validate_data or test_data) contains multiple samples, +or if multiple checkpoints should be compared or in general to be able to +set (monolithic) config file once and compute all steps without changing it +or needing different ones for validation and testing. +""" from copy import deepcopy import logging import os @@ -9,8 +16,7 @@ from linajea.utils import (CandidateDatabase, checkOrCreateDB) from linajea.config import (InferenceDataTrackingConfig, - SolveParametersMinimalConfig, - SolveParametersNonMinimalConfig, + SolveParametersConfig, TrackingConfig, maybe_fix_config_paths_to_machine_and_load) @@ -18,6 +24,45 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): + """ + + Args + ---- + args: argparse.Namespace or types.SimpleNamespace + Simple Namespace object with some (mostly optional, depending on + step that should be computed) attributes, e.g., the result of + calling parse_args on an argparse.ArgumentParser or by constructing + a types.SimpleNamespace manually. + + Attributes + ---------- + config: Path + Mandatory,path to the configuration file that should be used + validation: bool + Compute results on validate_data (or on test_data) + validate_on_train: bool + Use train_data for validation, for debugging/checking for + overfitting + checkpoint: int + Can be used to overwrite value of checkpoint contained in config + param_list_idx: int + Index into list of parameter sets in config.solve.parameters + Only this element will be computed. + val_param_id: int + Load parameters from validation database with this ID, use it for + test data; only works with a single data sample (otherwise ID might + not be unique) + param_id: int + Load parameters from current database with this ID and compute + param_ids: list of int + Load sets of parameters with these IDs from current database + and compute; if two elements, interpreted as range; if more + than two elements, interpreted as list. + is_solve: bool + Compute solving step + is_evaluate: bool + Compute evaluation step + """ config = maybe_fix_config_paths_to_machine_and_load(args.config) config = TrackingConfig(**config) # print(config) @@ -62,11 +107,11 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): for checkpoint in checkpoints: if hasattr(args, "val_param_id") and (is_solve or is_evaluate) and \ args.val_param_id is not None: - config = fix_val_param_pid(args, config, checkpoint) + config = _fix_val_param_pid(args, config, checkpoint) if hasattr(args, "param_id") and (is_solve or is_evaluate) and \ (args.param_id is not None or (hasattr(args, "param_ids") and args.param_ids is not None)): - config = fix_param_pid(args, config, checkpoint, inference_data) + config = _fix_param_pid(args, config, checkpoint, inference_data) inference_data_tmp = { 'checkpoint': checkpoint, 'cell_score_threshold': inference_data.cell_score_threshold} @@ -80,22 +125,19 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): sample.datafile.filename, checkpoint, inference_data.cell_score_threshold, - roi=sample.roi, + roi=attr.asdict(sample.roi), tag=config.general.tag) inference_data_tmp['data_source'] = sample - config.inference = InferenceDataTrackingConfig(**inference_data_tmp) # type: ignore + config.inference_data = InferenceDataTrackingConfig(**inference_data_tmp) # type: ignore if is_solve: - config = fix_solve_roi(config) - if config.solve.write_struct_svm: - config.solve.write_struct_svm += "_ckpt_{}_{}".format( - checkpoint, os.path.basename(sample.datafile.filename)) + config = _fix_solve_roi(config) if is_evaluate: print(solve_parameters_sets, len(solve_parameters_sets)) for solve_parameters in solve_parameters_sets: solve_parameters = deepcopy(solve_parameters) config.solve.parameters = [solve_parameters] - config = fix_solve_roi(config) + config = _fix_solve_roi(config) yield config continue @@ -106,7 +148,7 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): yield config -def fix_val_param_pid(args, config, checkpoint): +def _fix_val_param_pid(args, config, checkpoint): if hasattr(args, "validation") and args.validation: tmp_data = config.test_data else: @@ -127,13 +169,13 @@ def fix_val_param_pid(args, config, checkpoint): pid = args.val_param_id - config = fix_solve_parameters_with_pids( + config = _fix_solve_parameters_with_pids( config, [pid], db_meta_info, db_name) config.solve.parameters[0].val = False return config -def fix_param_pid(args, config, checkpoint, inference_data): +def _fix_param_pid(args, config, checkpoint, inference_data): assert len(inference_data.data_sources) == 1, ( "param_id(s) only supported with a single sample") if inference_data.data_sources[0].db_name is None: @@ -148,24 +190,24 @@ def fix_param_pid(args, config, checkpoint, inference_data): db_meta_info = None if hasattr(args, "param_ids") and args.param_ids is not None: - if len(args.param_ids) > 2: - pids = args.param_ids - else: + if len(args.param_ids) == 2: pids = list(range(int(args.param_ids[0]), int(args.param_ids[1])+1)) + else: + pids = args.param_ids else: pids = [args.param_id] - config = fix_solve_parameters_with_pids(config, pids, db_meta_info, db_name) + config = _fix_solve_parameters_with_pids(config, pids, db_meta_info, db_name) return config -def fix_solve_roi(config): +def _fix_solve_roi(config): for i in range(len(config.solve.parameters)): config.solve.parameters[i].roi = config.inference_data.data_source.roi return config -def fix_solve_parameters_with_pids(config, pids, db_meta_info=None, db_name=None): +def _fix_solve_parameters_with_pids(config, pids, db_meta_info=None, db_name=None): if db_name is None: db_name = checkOrCreateDB( config.general.db_host, @@ -173,7 +215,7 @@ def fix_solve_parameters_with_pids(config, pids, db_meta_info=None, db_name=None db_meta_info["sample"], db_meta_info["iteration"], db_meta_info["cell_score_threshold"], - roi=db_meta_info["roi"], + roi=attr.asdict(db_meta_info["roi"]), tag=config.general.tag, create_if_not_found=False) assert db_name is not None, "db for pid {} not found".format(pids) @@ -197,11 +239,6 @@ def fix_solve_parameters_with_pids(config, pids, db_meta_info=None, db_name=None logger.info("getting params %s (id: %s) from database %s (sample: %s)", parameters, pid, db_name, db_meta_info["sample"] if db_meta_info is not None else None) - try: - solve_parameters = SolveParametersMinimalConfig(**parameters) # type: ignore - config.solve.non_minimal = False - except TypeError: - solve_parameters = SolveParametersNonMinimalConfig(**parameters) # type: ignore - config.solve.non_minimal = True + solve_parameters = SolveParametersConfig(**parameters) # type: ignore config.solve.parameters.append(solve_parameters) return config diff --git a/linajea/utils/parse_tracks_file.py b/linajea/utils/parse_tracks_file.py index 754bb0d..a771db2 100644 --- a/linajea/utils/parse_tracks_file.py +++ b/linajea/utils/parse_tracks_file.py @@ -1,3 +1,8 @@ +"""Provides function to read tracks from csv text file + +First checks if file has header, if yes uses it to parse file, +if no assumes default order of columns +""" import csv import logging @@ -8,20 +13,11 @@ logger = logging.getLogger(__name__) -def get_dialect_and_header(csv_file): - with open(csv_file, 'r') as f: - dialect = csv.Sniffer().sniff(f.read(1024)) - f.seek(0) - has_header = csv.Sniffer().has_header(f.read(1024)) - - return dialect, has_header - - def parse_tracks_file( filename, scale=1.0, limit_to_roi=None): - dialect, has_header = get_dialect_and_header(filename) + dialect, has_header = _get_dialect_and_header(filename) logger.debug("Tracks file has header: %s" % has_header) if has_header: locations, track_info = \ @@ -32,6 +28,15 @@ def parse_tracks_file( return locations, track_info +def _get_dialect_and_header(csv_file): + with open(csv_file, 'r') as f: + dialect = csv.Sniffer().sniff(f.read(1024)) + f.seek(0) + has_header = csv.Sniffer().has_header(f.read(1024)) + + return dialect, has_header + + def _parse_csv_ndims(filename, scale=1.0, limit_to_roi=None, read_dims=4): '''Read one point per line. If ``read_dims`` is 0, all values in one line are considered as the location of the point. If positive, @@ -82,7 +87,7 @@ def _parse_csv_fields(filename, scale=1.0, limit_to_roi=None): ''' locations = [] track_info = [] - dialect, has_header = get_dialect_and_header(filename) + dialect, has_header = _get_dialect_and_header(filename) with open(filename, 'r') as f: assert has_header, "No header found, but this function needs a header" reader = csv.DictReader(f, fieldnames=None, diff --git a/linajea/utils/print_time.py b/linajea/utils/print_time.py index 320242b..d9fe95b 100644 --- a/linajea/utils/print_time.py +++ b/linajea/utils/print_time.py @@ -1,3 +1,5 @@ +"""Provides a function to pretty print a time duration +""" import logging logger = logging.getLogger(__name__) diff --git a/linajea/utils/write_search_files.py b/linajea/utils/write_search_files.py index 95c2d99..053ac21 100644 --- a/linajea/utils/write_search_files.py +++ b/linajea/utils/write_search_files.py @@ -1,3 +1,5 @@ +"""Provides a script to write (grid) search parameters as list to toml file +""" import argparse import itertools import logging @@ -90,13 +92,6 @@ def write_search_configs(config, random_search, output_file, num_configs=None): conf[k] = value search_configs.append(conf) else: - if params.get('cell_cycle_key') == '': - params['cell_cycle_key'] = None - elif isinstance(params.get('cell_cycle_key'), list) and \ - '' in params['cell_cycle_key']: - params['cell_cycle_key'] = [k if k != '' else None - for k in params['cell_cycle_key']] - search_configs = [ dict(zip(params.keys(), x)) for x in itertools.product(*params.values())] @@ -105,7 +100,7 @@ def write_search_configs(config, random_search, output_file, num_configs=None): random.shuffle(search_configs) search_configs = search_configs[:num_configs] - search_configs = {"solve" : { "parameters": search_configs}} + search_configs = {"parameters": search_configs} with open(output_file, 'w') as f: print(search_configs) toml.dump(search_configs, f) From 492fc378736981cfd06e39992abcc4c96cc9cb08 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 1 Jul 2022 13:15:36 -0400 Subject: [PATCH 205/263] cleanup run_scripts, prune to main method --- linajea/run_scripts/01_train.py | 6 + linajea/run_scripts/02_predict_blockwise.py | 9 +- linajea/run_scripts/03_extract_edges.py | 8 +- linajea/run_scripts/04_solve.py | 13 ++- linajea/run_scripts/05_evaluate.py | 13 +++ linajea/run_scripts/06_run_best_config.py | 106 ++++++++++++++++++ linajea/run_scripts/config_example.toml | 30 ++--- .../config_example_drosophila.toml | 2 +- linajea/run_scripts/config_example_mouse.toml | 2 +- 9 files changed, 161 insertions(+), 28 deletions(-) create mode 100644 linajea/run_scripts/06_run_best_config.py diff --git a/linajea/run_scripts/01_train.py b/linajea/run_scripts/01_train.py index afde0bc..d49a8bf 100644 --- a/linajea/run_scripts/01_train.py +++ b/linajea/run_scripts/01_train.py @@ -1,3 +1,9 @@ +"""Training run script + +Loads the configuration and starts training. +Uses data specified under [train_data] +Writes logging output to stdout and run.log file +""" import argparse import logging import os diff --git a/linajea/run_scripts/02_predict_blockwise.py b/linajea/run_scripts/02_predict_blockwise.py index 750947d..0cd66b2 100644 --- a/linajea/run_scripts/02_predict_blockwise.py +++ b/linajea/run_scripts/02_predict_blockwise.py @@ -1,4 +1,9 @@ -from __future__ import absolute_import +"""Prediction run script + +Loads the configuration and predicts. +Expects data specified as [validate_data] and [test_data] +Automatically selects data, sets db name etc based on config +""" import argparse import logging import time @@ -9,9 +14,7 @@ logging.basicConfig( - # wrong crop: switch level=logging.INFO, - # level=logging.DEBUG, format='%(asctime)s %(name)s %(levelname)-8s %(message)s') logger = logging.getLogger(__name__) diff --git a/linajea/run_scripts/03_extract_edges.py b/linajea/run_scripts/03_extract_edges.py index e03d1c9..16ae67f 100644 --- a/linajea/run_scripts/03_extract_edges.py +++ b/linajea/run_scripts/03_extract_edges.py @@ -1,4 +1,10 @@ -from __future__ import absolute_import +"""Extract Edges run script + +Loads the configuration and computes edge candidates. +Expects data specified as [validate_data] and [test_data] +Automatically selects data; if db name not set, set automatically +based on data +""" import argparse import logging import time diff --git a/linajea/run_scripts/04_solve.py b/linajea/run_scripts/04_solve.py index 9aa0774..8538ca5 100644 --- a/linajea/run_scripts/04_solve.py +++ b/linajea/run_scripts/04_solve.py @@ -1,3 +1,12 @@ +"""Solve run script + +Loads the configuration and solves the ILP. +Expects data specified as [validate_data] and [test_data] +Automatically selects data; if db name not set, set automatically +based on data. +If weights/parameters search is specified, automatically creates +parameter sets. +""" from __future__ import absolute_import import argparse import logging @@ -28,8 +37,8 @@ help='get test parameters from validation parameters_id') parser.add_argument('--param_id', type=int, default=None, help='process parameters with parameters_id (e.g. resolve set of parameters)') - parser.add_argument('--param_ids', default=None, nargs=2, - help='start/end range of eval parameters_ids') + parser.add_argument('--param_ids', default=None, nargs=+, + help='start/end range or list of eval parameters_ids') parser.add_argument('--param_list_idx', type=str, default=None, help='only solve idx parameter set in config') args = parser.parse_args() diff --git a/linajea/run_scripts/05_evaluate.py b/linajea/run_scripts/05_evaluate.py index d9f80bf..1a42848 100644 --- a/linajea/run_scripts/05_evaluate.py +++ b/linajea/run_scripts/05_evaluate.py @@ -1,3 +1,14 @@ +"""Evaluate run script + +Loads the configuration and solves the ILP. +Expects data specified as [validate_data] and [test_data] +Automatically selects data; if db name not set, set automatically +based on data. +If weights/parameters search is supposed to be evaluated and run +separately from 04_solve.py/without run.py, disable grid_search and +random_search and supply --param_ids to select which parameter sets +stored in the database should be evaluated. +""" import argparse import logging import time @@ -27,6 +38,8 @@ help='get test parameters from validation parameters_id') parser.add_argument('--param_id', default=None, help='process parameters with parameters_id') + parser.add_argument('--param_ids', default=None, nargs=+, + help='start/end range or list of eval parameters_ids') parser.add_argument('--param_list_idx', type=str, default=None, help='only eval parameters[idx] in config') args = parser.parse_args() diff --git a/linajea/run_scripts/06_run_best_config.py b/linajea/run_scripts/06_run_best_config.py new file mode 100644 index 0000000..b730777 --- /dev/null +++ b/linajea/run_scripts/06_run_best_config.py @@ -0,0 +1,106 @@ +"""Get best val and eval on test run script + +Finds the parameters with the best result on the validation data +Solves and evaluates with those on the test data +""" +from __future__ import absolute_import +import argparse +import logging +import os +import time + +import attr +import pandas as pd +import toml + +from linajea.config import (maybe_fix_config_paths_to_machine_and_load, + SolveParametersConfig, + TrackingConfig) +from linajea.utils import (print_time, + getNextInferenceData) +from linajea.process_blockwise import solve_blockwise +import linajea.evaluation + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(name)s %(levelname)-8s %(message)s') +logger = logging.getLogger(__name__) + +if __name__ == "__main__": + + parser = argparse.ArgumentParser() + parser.add_argument('--config', type=str, + help='path to config file') + parser.add_argument('--checkpoint', type=int, default=-1, + help='checkpoint to process') + parser.add_argument('--swap_val_test', action="store_true", + help='swap validation and test data?') + parser.add_argument('--sort_by', type=str, default="sum_errors", + help='Which metric to use to select best parameters/weights') + args = parser.parse_args() + + config = maybe_fix_config_paths_to_machine_and_load(args.config) + config = TrackingConfig(**config) + + results = {} + args.validation = not args.swap_val_test + for sample_idx, inf_config in enumerate(getNextInferenceData(args, + is_evaluate=True)): + sample = inf_config.inference_data.data_source.datafile.filename + logger.debug("getting results for:", sample) + + res = linajea.evaluation.get_results_sorted( + inf_config, + filter_params={"val": True}, + sort_by=args.sort_by) + + results[os.path.basename(sample)] = res.reset_index() + args.validation = not args.validation + + results = pd.concat(list(results.values())).reset_index() + del results['_id'] + del results['param_id'] + + solve_params = attr.fields_dict(SolveParametersConfig) + results = results.groupby(lambda x: str(x), dropna=False, as_index=False).agg( + lambda x: + -1 + if len(x) != sample_idx+1 + else sum(x) + if (not isinstance(x.iloc[0], list) and + not isinstance(x.iloc[0], dict) and + not isinstance(x.iloc[0], str) + ) + else x.iloc[0]) + + results = results[results.sum_errors != -1] + results.sort_values(args.sort_by, ascending=True, inplace=True) + + for k in solve_params.keys(): + if k == "tag" and k not in results.iloc[0]: + solve_params[k] = None + continue + solve_params[k] = results.iloc[0][k] + solve_params['val'] = False + + config.path = os.path.join("tmp_configs", "config_{}.toml".format( + time.time())) + config_dict = attr.asdict(config) + config_dict['solve']['parameters'] = [solve_params] + config_dict['solve']['grid_search'] = False + config_dict['solve']['random_search'] = False + with open(config.path, 'w') as f: + toml.dump(config_dict, f, encoder=toml.TomlNumpyEncoder()) + args.config = config.path + + start_time = time.time() + for inf_config in getNextInferenceData(args, is_evaluate=True): + solve_blockwise(inf_config) + end_time = time.time() + print_time(end_time - start_time) + + start_time = time.time() + for inf_config in getNextInferenceData(args, is_solve=True): + linajea.evaluation.evaluate_setup(inf_config) + end_time = time.time() + print_time(end_time - start_time) diff --git a/linajea/run_scripts/config_example.toml b/linajea/run_scripts/config_example.toml index 82025bf..162480b 100644 --- a/linajea/run_scripts/config_example.toml +++ b/linajea/run_scripts/config_example.toml @@ -38,27 +38,24 @@ upsampling = "sep_transposed_conv" average_vectors = false # nms_window_shape = [ 3, 11, 11,] nms_window_shape = [ 3, 9, 9,] -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/mknet_celegans.py" cell_indicator_weighted = 0.01 cell_indicator_cutoff = 0.01 # cell_indicator_weighted = true -latent_temp_conv = false train_only_cell_indicator = false [train] val_log_step = 25 # radius for binary map -> *2 (in world units) # in which to draw parent vectors (not used if use_radius) -parent_radius = [ 0.1, 8.0, 8.0, 8.0,] +object_radius = [ 0.1, 8.0, 8.0, 8.0,] # upper bound for dist cell moved between two frames (needed for context) move_radius = 25 # sigma for Gauss -> ~*4 (5 in z -> in 3 slices) (not used if use_radius) rasterize_radius = [ 0.1, 5.0, 3.0, 3.0,] cache_size = 1 -parent_vectors_loss_transition_offset = 20000 -parent_vectors_loss_transition_factor = 0.001 +movement_vectors_loss_transition_offset = 20000 +movement_vectors_loss_transition_factor = 0.001 #use_radius = true -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/train_val_celegans_torch.py" max_iterations = 11 checkpoint_stride = 10 snapshot_stride = 5 @@ -88,16 +85,10 @@ block_size = [ 5, 512, 512, 512,] [solve] from_scratch = true -non_minimal = false greedy = false # write_struct_svm = "ssvm_ckpt_200000" check_node_close_to_roi = false -add_node_density_constraints = false # random_search = false -# [solve.add_node_density_constraints] -# 30 = 13 -# 60 = 11 -# 10000 = 7 [[solve.parameters]] @@ -150,9 +141,7 @@ num_configs = 5 from_scratch = false [predict] -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/predict_celegans_torch.py" -path_to_script_db_from_zarr = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/write_cells_celegans.py" -output_zarr_prefix = "/nrs/funke/hirschp/linajea_experiments" +output_zarr_dir = "/nrs/funke/hirschp/linajea_experiments" write_to_zarr = false write_to_db = true write_db_from_zarr = false @@ -199,13 +188,15 @@ group = "volumes/raw_nested" num_workers = 1 queue = "gpu_tesla" -[train.augment] -divisions = true -reject_empty_prob = 0.9 -normalization = "minmax" +[train.normalization] +type = "minmax" perc_min = "perc0_01" perc_max = "perc99_99" norm_bounds = [ 2000, 7500,] + +[train.augment] +divisions = true +reject_empty_prob = 0.9 point_balance_radius = 75 [optimizerTorch.kwargs] @@ -231,7 +222,6 @@ queue = "local" [evaluate.parameters] matching_threshold = 15 -sparse = false [evaluate.job] num_workers = 1 diff --git a/linajea/run_scripts/config_example_drosophila.toml b/linajea/run_scripts/config_example_drosophila.toml index 884e753..b638c96 100644 --- a/linajea/run_scripts/config_example_drosophila.toml +++ b/linajea/run_scripts/config_example_drosophila.toml @@ -104,7 +104,7 @@ queue = "gpu_tesla" [extract] block_size = [ 7, 500, 500, 500,] # edge_move_threshold = 40 -use_pv_distance = true +use_mv_distance = true [extract.edge_move_threshold] -1 = 25 diff --git a/linajea/run_scripts/config_example_mouse.toml b/linajea/run_scripts/config_example_mouse.toml index 2555bb0..5a2cdad 100644 --- a/linajea/run_scripts/config_example_mouse.toml +++ b/linajea/run_scripts/config_example_mouse.toml @@ -104,7 +104,7 @@ queue = "gpu_tesla" [extract] block_size = [ 5, 500, 500, 500,] # edge_move_threshold = 40 -use_pv_distance = true +use_mv_distance = true [extract.edge_move_threshold] -1 = 40 From 156008220f07cc5dd78120a85d92082d1ac1572b Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 4 Jul 2022 06:56:02 -0400 Subject: [PATCH 206/263] re-add division eval script --- linajea/evaluation/division_evaluation.py | 231 ++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 linajea/evaluation/division_evaluation.py diff --git a/linajea/evaluation/division_evaluation.py b/linajea/evaluation/division_evaluation.py new file mode 100644 index 0000000..33e814c --- /dev/null +++ b/linajea/evaluation/division_evaluation.py @@ -0,0 +1,231 @@ +"""Provides a function to evaluate the recreated divisions + +Can match divisions not only in the same frame as the gt divisions +but also in adjacent frames (by setting frame_buffer) +""" +import logging + +import numpy as np +import scipy.spatial + +from .match import match + +logger = logging.getLogger(__name__) + + +def evaluate_divisions( + gt_divisions, + rec_divisions, + target_frame, + matching_threshold, + frame_buffer=0, + output_file=None): + ''' Full frame division evaluation + Arguments: + gt_divisions (dict: int -> list) + Dictionary from frame to [[z y x id], ...] + + rec_divisions (dict: int -> list) + Dictionary from frame to [[z y x id], ...] + + target_frame (int) + + matching_threshold (int) + + frame_buffer (int): + Number of adjacent frames that are also fully + annotated in gt_divisions. Default is 0. + + output_file (string): + If given, save the results to the file + + Output: + A set of division reports with TP, FP, FN, TN, accuracy, precision + and recall values for divisions in the target frame. Each report will + give a different amount of leniency in the time localization of the + division, from 0 to frame_buffer. For example, with a leniency of 1 + frame, GT divisions in the target frame can be matched to rec divisions + in +-1 frame when calculating FN, and rec divisions in the + target frame can be matched to GT divisions in +-1 frame when + calculating FP. Rec divisions in +-1 will NOT be counted as FPs however + (because we would need GT in +-2 to confirm if they are FP or TP), and + same for GT divisions in +-1 not counting as FNs. + + Note: if you have dense ground truth, it is better to do a global + hungarian matching with whatever leniency you want, and then calculate + a global report, not one focusing on a single frame. + + Algorithm: + Make a KD tree for each division point in each frame of GT and REC. + Match GT and REC divisions in target_frame using hungarian matching, + calculate report. + For unmatched GT divisions in target_frame, try matching to rec in +-1. + For unmatched REC divisions in target_frame, try matching to GT in +-1. + Calculate updated report. + Repeat last 2 lines until leniency reaches frame buffer. + ''' + gt_kd_trees = {} + gt_node_ids = {} + rec_kd_trees = {} + rec_node_ids = {} + + for b in range(0, frame_buffer + 1): + if b == 0: + ts = [target_frame] + else: + ts = [target_frame - b, target_frame + b] + rec_nodes = [] + gt_nodes = [] + rec_positions = [] + gt_positions = [] + for t in ts: + if t in rec_divisions: + # ids only + rec_nodes.extend([n[3] for n in rec_divisions[t]]) + # positions only + rec_positions.extend([n[0:3] for n in rec_divisions[t]]) + + if t in gt_divisions: + # ids only + gt_nodes.extend([n[3] for n in gt_divisions[t]]) + # positions only + gt_positions.extend([n[0:3] for n in gt_divisions[t]]) + + if len(gt_positions) > 0: + gt_kd_trees[b] = scipy.spatial.cKDTree(gt_positions) + else: + gt_kd_trees[b] = None + gt_node_ids[b] = gt_nodes + if len(rec_positions) > 0: + rec_kd_trees[b] = scipy.spatial.cKDTree(rec_positions) + else: + rec_kd_trees[b] = None + rec_node_ids[b] = rec_nodes + + matches = [] + gt_target_tree = gt_kd_trees[0] + rec_target_tree = rec_kd_trees[0] + logger.debug("Node Ids gt: %s", gt_node_ids) + logger.debug("Node Ids rec: %s", rec_node_ids) + reports = [] + for b in range(0, frame_buffer + 1): + if b == 0: + # Match GT and REC divisions in target_frame using hungarian + # matching, calculate report. + costs = construct_costs(gt_target_tree, gt_node_ids[0], + rec_target_tree, rec_node_ids[0], + matching_threshold) + if len(costs) == 0: + matches = [] + else: + matches, soln_cost = match(costs, + matching_threshold + 1) + logger.info("found %d matches in target frame" % len(matches)) + report = calculate_report(gt_node_ids, rec_node_ids, matches) + reports.append(report) + logger.info("report in target frame: %s", report) + else: + # For unmatched GT divisions in target_frame, + # try matching to rec in +-b. + matched_gt = [m[0] for m in matches] + gt_costs = construct_costs( + gt_target_tree, gt_node_ids[0], + rec_kd_trees[b], rec_node_ids[b], + matching_threshold, + exclude_gt=matched_gt) + if len(gt_costs) == 0: + gt_matches = [] + else: + gt_matches, soln_cost = match( + gt_costs, + matching_threshold + 1) + logger.info("Found %d gt matches in frames +-%d", + len(gt_matches), b) + matches.extend(gt_matches) + # For unmatched REC divisions in target_frame, + # try matching to GT in +-b. + matched_rec = [m[1] for m in matches] + rec_costs = construct_costs( + gt_kd_trees[b], gt_node_ids[b], + rec_target_tree, rec_node_ids[0], + matching_threshold, + exclude_rec=matched_rec) + if len(rec_costs) == 0: + rec_matches = [] + else: + rec_matches, soln_cost = match( + rec_costs, + matching_threshold + 1) + logger.info("Found %d rec matches in frames +-%d", + len(rec_matches), b) + matches.extend(rec_matches) + + # Calculate updated report. + report = calculate_report(gt_node_ids, rec_node_ids, matches) + reports.append(report) + logger.info("report +-%d: %s", b, report) + if output_file: + save_results_to_file(reports, output_file) + return reports + + +def construct_costs( + gt_tree, gt_nodes, + rec_tree, rec_nodes, + matching_threshold, + exclude_gt=[], + exclude_rec=[]): + costs = {} + if gt_tree is None or rec_tree is None: + return costs + neighbors = gt_tree.query_ball_tree(rec_tree, matching_threshold) + for i, js in enumerate(neighbors): + gt_node = gt_nodes[i] + if gt_node in exclude_gt: + continue + for j in js: + rec_node = rec_nodes[j] + if rec_node in exclude_rec: + continue + distance = np.linalg.norm( + np.array(gt_tree.data[i]) - + np.array(rec_tree.data[j])) + costs[(gt_node, rec_node)] = distance + return costs + + +def calculate_report( + gt_node_ids, + rec_node_ids, + matches): + matched_gt = [m[0] for m in matches] + matched_rec = [m[1] for m in matches] + + # gt_total, rec_total, FP, FN, Prec, Rec, F1 + gt_target_divs = gt_node_ids[0] + rec_target_divs = rec_node_ids[0] + gt_total = len(gt_target_divs) + rec_total = len(rec_target_divs) + fp_nodes = [n for n in rec_target_divs + if n not in matched_rec] + fp = len(fp_nodes) + fn_nodes = [n for n in gt_target_divs + if n not in matched_gt] + fn = len(fn_nodes) + prec = (rec_total - fp) / rec_total if rec_total > 0 else None + rec = (gt_total - fn) / gt_total if gt_total > 0 else None + f1 = (2 * prec * rec / (prec + rec) + if prec is not None and rec is not None and prec + rec > 0 + else None) + return (gt_total, rec_total, fp, fn, prec, rec, f1) + + +def save_results_to_file(reports, filename): + header = "frames, gt_total, rec_total, FP, FN, Prec, Rec, F1\n" + with open(filename, 'w') as f: + f.write(header) + for frames, report in enumerate(reports): + f.write(str(frames)) + f.write(", ") + f.write(", ".join(list(map(str, report)))) + f.write("\n") From 962f74ab7d98b56f4361ef6590d1e6c33d570727 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 4 Jul 2022 06:57:34 -0400 Subject: [PATCH 207/263] cleanup and fix tests --- linajea/config/utils.py | 4 +- linajea/evaluation/__init__.py | 3 +- linajea/evaluation/evaluate.py | 2 + linajea/evaluation/evaluator.py | 13 +- linajea/evaluation/match.py | 23 +- linajea/process_blockwise/solve_blockwise.py | 2 +- linajea/tracking/greedy_track.py | 16 +- linajea/tracking/solver.py | 3 - linajea/training/train.py | 6 +- tests/test_candidate_database.py | 22 +- tests/test_division_evaluation.py | 2 +- tests/test_evaluation.py | 99 +++-- tests/test_greedy_baseline.py | 31 +- tests/test_minimal_solver.py | 444 ------------------- tests/test_non_minimal_solver.py | 243 ---------- tests/test_split_into_tracks.py | 6 +- tests/test_tracks_source.py | 62 +-- tests/test_validation_metric.py | 29 +- tests/test_write_cells.py | 17 +- 19 files changed, 187 insertions(+), 840 deletions(-) delete mode 100644 tests/test_minimal_solver.py delete mode 100644 tests/test_non_minimal_solver.py diff --git a/linajea/config/utils.py b/linajea/config/utils.py index 4e0b375..bdcb523 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -100,8 +100,8 @@ def converter(vals): if isinstance(vals, str) and vals.endswith(".toml"): vals = load_config(vals) - assert isinstance(vals, list), "list of {} expected ({})".format( - cl, vals) + if not isinstance(vals, list): + vals = [vals] converted = [] for val in vals: if isinstance(val, cl) or val is None: diff --git a/linajea/evaluation/__init__.py b/linajea/evaluation/__init__.py index 68d570b..95d7168 100644 --- a/linajea/evaluation/__init__.py +++ b/linajea/evaluation/__init__.py @@ -1,6 +1,7 @@ # flake8: noqa -from .match import match_edges +from .match import match_edges, match, get_edge_costs from .evaluate_setup import evaluate_setup +from .evaluate import evaluate from .report import Report from .analyze_results import (get_results_sorted, get_best_result_config, diff --git a/linajea/evaluation/evaluate.py b/linajea/evaluation/evaluate.py index 7a81119..0b87e97 100644 --- a/linajea/evaluation/evaluate.py +++ b/linajea/evaluation/evaluate.py @@ -67,6 +67,8 @@ def evaluate( logger.info("Done matching. Evaluating") edge_matches = [(gt_edges[gt_ind], rec_edges[rec_ind]) for gt_ind, rec_ind in edge_matches] + unselected_potential_matches = [rec_edges[rec_ind] + for rec_ind in unselected_potential_matches] evaluator = Evaluator( gt_track_graph, rec_track_graph, diff --git a/linajea/evaluation/evaluator.py b/linajea/evaluation/evaluator.py index d0d31e1..b0ae8ab 100644 --- a/linajea/evaluation/evaluator.py +++ b/linajea/evaluation/evaluator.py @@ -124,16 +124,11 @@ def get_fp_edges(self): If dense, this is the total number of unmatched rec edges. ''' if self.sparse: - num_fp_edges = self.unselected_potential_matches + fp_edges = self.unselected_potential_matches else: - num_fp_edges = self.report.rec_edges - self.report.matched_edges - - matched_edges = set([match[1] for match in self.edge_matches]) - rec_edges = set(self.rec_track_graph.edges) - fp_edges = list(rec_edges - matched_edges) - assert len(fp_edges) == num_fp_edges, "List of fp edges "\ - "has %d edges, but calculated %d fp edges"\ - % (len(fp_edges), num_fp_edges) + matched_edges = set([match[1] for match in self.edge_matches]) + rec_edges = set(self.rec_track_graph.edges) + fp_edges = list(rec_edges - matched_edges) self.report.set_fp_edges(fp_edges) diff --git a/linajea/evaluation/match.py b/linajea/evaluation/match.py index 5217355..2bc2ac5 100644 --- a/linajea/evaluation/match.py +++ b/linajea/evaluation/match.py @@ -47,7 +47,7 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): # list of (neighboring node in y, distance) node_pairs_xy_by_frame = {} edge_matches = [] - edge_fps = 0 + edge_fps = [] avg_dist = [] avg_dist_target = [] @@ -107,7 +107,7 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): logger.debug("finding matches in frame %d" % t) node_pairs_two_frames = node_pairs_xy.copy() node_pairs_two_frames.update(node_pairs_xy_by_frame[t-1]) - edge_costs = _get_edge_costs( + edge_costs = get_edge_costs( edges_x, edges_y_by_source, node_pairs_two_frames) if edge_costs == {}: logger.info("No potential matches with source in frame %d" % t) @@ -115,15 +115,16 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): logger.debug("costs: %s" % edge_costs) y_edges_in_range = set(edge[1] for edge in edge_costs.keys()) logger.debug("Y edges in range: %s" % y_edges_in_range) - edge_matches_in_frame, _ = _match(edge_costs, - 2*matching_threshold + 1) + edge_matches_in_frame, _ = match(edge_costs, + 2*matching_threshold + 1) edge_matches.extend(edge_matches_in_frame) - edge_fps_in_frame = len(y_edges_in_range) -\ - len(edge_matches_in_frame) - edge_fps += edge_fps_in_frame + y_edge_matches_in_frame = [edge[1] for edge in edge_matches_in_frame] + edge_fps_in_frame = set(y_edges_in_range) -\ + set(y_edge_matches_in_frame) + edge_fps += list(edge_fps_in_frame) logger.debug( "Done matching frame %d, found %d matches and %d edge fps", - t, len(edge_matches_in_frame), edge_fps_in_frame) + t, len(edge_matches_in_frame), len(edge_fps_in_frame)) for exid, eyid in edge_matches_in_frame: node_xid_source = edges_x[exid][0] @@ -206,11 +207,11 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): np.min(avg_dist), np.max(avg_dist)) logger.info("Done matching, found %d matches and %d edge fps" - % (len(edge_matches), edge_fps)) + % (len(edge_matches), len(edge_fps))) return edges_x, edges_y, edge_matches, edge_fps -def _get_edge_costs(edges_x, edges_y_by_source, node_pairs_xy): +def get_edge_costs(edges_x, edges_y_by_source, node_pairs_xy): ''' Arguments: edges_x (list of int): @@ -250,7 +251,7 @@ def _get_edge_costs(edges_x, edges_y_by_source, node_pairs_xy): return edge_costs -def _match(costs, no_match_cost): +def match(costs, no_match_cost): ''' Arguments: costs (``dict`` from ``tuple`` of ids to ``float``): diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index 0a0712e..ccb701c 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -222,7 +222,7 @@ def solve_in_block(linajea_config, read_roi.get_offset()[0] + read_roi.get_shape()[0]] if linajea_config.solve.greedy: greedy_track(graph=graph, selected_key=selected_keys[0], - cell_indicator_threshold=0.2) + node_threshold=0.2) else: track(graph, linajea_config, selected_keys, frames=frames, block_id=block.block_id[1]) diff --git a/linajea/tracking/greedy_track.py b/linajea/tracking/greedy_track.py index b488c08..f9f96d6 100644 --- a/linajea/tracking/greedy_track.py +++ b/linajea/tracking/greedy_track.py @@ -45,7 +45,7 @@ def greedy_track( db_name=None, db_host=None, selected_key=None, - cell_indicator_threshold=None, + node_threshold=0.0, metric='prediction_distance', frame_key='t', allow_new_tracks=True, @@ -64,7 +64,7 @@ def greedy_track( Host for database selected_key: d Edge attribute to use for storing results - cell_indicator_threshold: float + node_threshold: float Discard node candidates with a lower score metric: str Which edge attribute to use to rank neighbors @@ -109,7 +109,7 @@ def greedy_track( cand_db, section_roi, selected_key, - cell_indicator_threshold, + node_threshold, selected_prev_nodes, metric=metric, frame_key=frame_key, @@ -124,7 +124,7 @@ def track_section( cand_db, roi, selected_key, - cell_indicator_threshold, + node_threshold, selected_prev_nodes, metric='prediction_distance', frame_key='t', @@ -150,7 +150,7 @@ def track_section( assert [p in seed_candidates for p in selected_prev_nodes],\ "previously selected nodes are not contained in current frame!" seeds = set([node for node in seed_candidates - if graph.nodes[node]['score'] > cell_indicator_threshold]) + if graph.nodes[node]['score'] > node_threshold]) logger.debug("Found %d potential seeds in frame %d", len(seeds), frame) # use only new (not previously selected) nodes to seed new tracks @@ -176,6 +176,9 @@ def track_section( logger.debug("Selecting shortest edges") for u, v, data in sorted_edges: + # check if child node score is above threshold: + if graph.nodes[v]['score'] < node_threshold: + continue # check if child already has selected out edge already_selected = len([ u @@ -206,6 +209,9 @@ def track_section( key=lambda e: e[2][metric]) for u, v, data in sorted_edges: + # check if child node score is above threshold: + if graph.nodes[v]['score'] < node_threshold: + continue # check if child already has selected out edge already_selected = len([ u diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index ee01b6d..2ce738f 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -173,9 +173,6 @@ def _check_node_close_to_roi_edge(self, node, data, distance): def _node_costs(self, node): # node score times a weight plus a threshold - logger.info("%s %s %s", type(self.graph.nodes[node]['score']), - type(self.parameters.weight_node_score), - type(self.parameters.selection_constant)) score_costs = ((self.graph.nodes[node]['score'] * self.parameters.weight_node_score) + self.parameters.selection_constant) diff --git a/linajea/training/train.py b/linajea/training/train.py index f8518af..5506962 100644 --- a/linajea/training/train.py +++ b/linajea/training/train.py @@ -2,15 +2,12 @@ Create model and train """ -from __future__ import print_function import warnings -warnings.filterwarnings("once", category=FutureWarning) -import argparse + import logging import time import os -import sys import numpy as np import torch @@ -29,7 +26,6 @@ from .utils import (get_latest_checkpoint, normalize) - logger = logging.getLogger(__name__) diff --git a/tests/test_candidate_database.py b/tests/test_candidate_database.py index 01d32c5..4d08d5b 100644 --- a/tests/test_candidate_database.py +++ b/tests/test_candidate_database.py @@ -1,11 +1,13 @@ -from linajea import CandidateDatabase -import linajea.tracking -from linajea.evaluation import Report -from daisy import Roi -from unittest import TestCase import logging import multiprocessing as mp import pymongo +from unittest import TestCase + +from daisy import Roi + +from linajea.utils import CandidateDatabase +import linajea.tracking +from linajea.evaluation import Report logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -111,7 +113,7 @@ def test_get_node_roi(self): db_name = 'test_linajea_db_node_roi' db_host = 'localhost' roi = Roi((0, 0, 0, 0), (5, 10, 10, 10)) - db = linajea.CandidateDatabase( + db = CandidateDatabase( db_name, db_host, mode='w') @@ -142,7 +144,7 @@ def test_write_and_get_score(self): "block_size": [5, 100, 100, 100], "context": [2, 100, 100, 100], } - parameters = linajea.config.SolveParametersMinimalConfig(**ps) + parameters = linajea.config.SolveParametersConfig(**ps) db = CandidateDatabase( db_name, @@ -188,7 +190,7 @@ def test_unique_id_one_worker(self): db_host, mode='w') for i in range(10): - tp = linajea.config.SolveParametersMinimalConfig( + tp = linajea.config.SolveParametersConfig( **self.get_tracking_params()) tp.track_cost = i _id = db.get_parameters_id(tp) @@ -198,13 +200,13 @@ def test_unique_id_one_worker(self): def test_unique_id_multi_worker(self): db_name = 'test_linajea_db_multi_worker' db_host = 'localhost' - db = linajea.CandidateDatabase( + db = CandidateDatabase( db_name, db_host, mode='w') tps = [] for i in range(10): - tp = linajea.config.SolveParametersMinimalConfig( + tp = linajea.config.SolveParametersConfig( **self.get_tracking_params()) tp.cost_appear = i tps.append(tp) diff --git a/tests/test_division_evaluation.py b/tests/test_division_evaluation.py index c68c70a..131056f 100644 --- a/tests/test_division_evaluation.py +++ b/tests/test_division_evaluation.py @@ -1,5 +1,5 @@ import unittest -from linajea.evaluation import evaluate_divisions +from linajea.evaluation.division_evaluation import evaluate_divisions class TestDivisionEval(unittest.TestCase): diff --git a/tests/test_evaluation.py b/tests/test_evaluation.py index a882a08..477166b 100644 --- a/tests/test_evaluation.py +++ b/tests/test_evaluation.py @@ -1,11 +1,13 @@ -import linajea.tracking -import linajea.evaluation as e import logging import unittest -import linajea -import daisy import pymongo +import daisy + +import linajea.evaluation as e +import linajea.tracking +import linajea.utils + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) logging.getLogger('linajea.evaluation').setLevel(logging.DEBUG) @@ -18,7 +20,7 @@ def delete_db(self): client.drop_database('test_eval') def create_graph(self, cells, edges, roi): - db = linajea.CandidateDatabase('test_eval', 'localhost') + db = linajea.utils.CandidateDatabase('test_eval', 'localhost') graph = db[roi] graph.add_nodes_from(cells) graph.add_edges_from(edges) @@ -68,8 +70,10 @@ def test_perfect_evaluation(self): cell[1]['y'] += 1 rec_track_graph = self.create_graph(cells, edges, roi) scores = e.evaluate( - gt_track_graph, rec_track_graph, matching_threshold=2, - sparse=True) + gt_track_graph, rec_track_graph, matching_threshold=2, + sparse=True, validation_score=None, window_size=5, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True) self.assertEqual(scores.matched_edges, 3) self.assertEqual(scores.fp_edges, 0) self.assertEqual(scores.fn_edges, 0) @@ -86,9 +90,9 @@ def test_perfect_evaluation(self): self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 1.0) self.assertAlmostEqual(scores.f_score, 1.0) - self.assertEqual(scores.correct_segments[1], (3, 3)) - self.assertEqual(scores.correct_segments[2], (2, 2)) - self.assertEqual(scores.correct_segments[3], (1, 1)) + self.assertEqual(scores.correct_segments["1"], (3, 3)) + self.assertEqual(scores.correct_segments["2"], (2, 2)) + self.assertEqual(scores.correct_segments["3"], (1, 1)) self.delete_db() def test_imperfect_evaluation(self): @@ -100,8 +104,10 @@ def test_imperfect_evaluation(self): del edges[1] rec_track_graph = self.create_graph(cells, edges, roi) scores = e.evaluate( - gt_track_graph, rec_track_graph, matching_threshold=2, - sparse=True) + gt_track_graph, rec_track_graph, matching_threshold=2, + sparse=True, validation_score=None, window_size=5, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True) self.assertEqual(scores.matched_edges, 2) self.assertEqual(scores.fp_edges, 0) self.assertEqual(scores.fn_edges, 1) @@ -118,9 +124,9 @@ def test_imperfect_evaluation(self): self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 2./3) self.assertAlmostEqual(scores.f_score, 4./5) - self.assertEqual(scores.correct_segments[1], (2, 3)) - self.assertEqual(scores.correct_segments[2], (0, 2)) - self.assertEqual(scores.correct_segments[3], (0, 1)) + self.assertEqual(scores.correct_segments["1"], (2, 3)) + self.assertEqual(scores.correct_segments["2"], (0, 2)) + self.assertEqual(scores.correct_segments["3"], (0, 1)) self.delete_db() def test_fn_division_evaluation(self): @@ -134,8 +140,11 @@ def test_fn_division_evaluation(self): edges.remove((5, 2)) rec_track_graph = self.create_graph(cells, edges, roi) scores = e.evaluate( - gt_track_graph, rec_track_graph, matching_threshold=2, - sparse=True) + gt_track_graph, rec_track_graph, matching_threshold=2, + sparse=True, + validation_score=None, window_size=5, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True) self.assertEqual(scores.matched_edges, 5) self.assertEqual(scores.fp_edges, 0) @@ -152,10 +161,10 @@ def test_fn_division_evaluation(self): self.assertEqual(scores.fp_divisions, 0) self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 5./6) - self.assertEqual(scores.correct_segments[1], (5, 6)) - self.assertEqual(scores.correct_segments[2], (2, 4)) - self.assertEqual(scores.correct_segments[3], (0, 2)) - self.assertEqual(scores.correct_segments[4], (0, 1)) + self.assertEqual(scores.correct_segments["1"], (5, 6)) + self.assertEqual(scores.correct_segments["2"], (2, 4)) + self.assertEqual(scores.correct_segments["3"], (0, 2)) + self.assertEqual(scores.correct_segments["4"], (0, 1)) self.delete_db() def test_fn_division_evaluation2(self): @@ -172,8 +181,10 @@ def test_fn_division_evaluation2(self): edges.remove((5, 2)) rec_track_graph = self.create_graph(cells, edges, roi) scores = e.evaluate( - gt_track_graph, rec_track_graph, matching_threshold=2, - sparse=True) + gt_track_graph, rec_track_graph, matching_threshold=2, + sparse=True, validation_score=None, window_size=5, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True) self.assertEqual(scores.matched_edges, 4) self.assertEqual(scores.fp_edges, 0) self.assertEqual(scores.fn_edges, 1) @@ -189,9 +200,9 @@ def test_fn_division_evaluation2(self): self.assertEqual(scores.fp_divisions, 0) self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 4./5) - self.assertEqual(scores.correct_segments[1], (4, 5)) - self.assertEqual(scores.correct_segments[2], (2, 3)) - self.assertEqual(scores.correct_segments[3], (0, 1)) + self.assertEqual(scores.correct_segments["1"], (4, 5)) + self.assertEqual(scores.correct_segments["2"], (2, 3)) + self.assertEqual(scores.correct_segments["3"], (0, 1)) self.delete_db() def test_fn_division_evaluation3(self): @@ -205,8 +216,10 @@ def test_fn_division_evaluation3(self): edges.remove((5, 2)) rec_track_graph = self.create_graph(cells, edges, roi) scores = e.evaluate( - gt_track_graph, rec_track_graph, matching_threshold=2, - sparse=True) + gt_track_graph, rec_track_graph, matching_threshold=2, + sparse=True, validation_score=None, window_size=5, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True) self.assertEqual(scores.matched_edges, 5) self.assertEqual(scores.fp_edges, 0) @@ -234,8 +247,10 @@ def test_fp_division_evaluation(self): cell[1]['y'] += 1 rec_track_graph = self.create_graph(cells, edges, roi) scores = e.evaluate( - gt_track_graph, rec_track_graph, matching_threshold=2, - sparse=True) + gt_track_graph, rec_track_graph, matching_threshold=2, + sparse=True, validation_score=None, window_size=5, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True) self.assertEqual(scores.matched_edges, 5) self.assertEqual(scores.fp_edges, 0) @@ -252,9 +267,9 @@ def test_fp_division_evaluation(self): self.assertEqual(scores.fp_divisions, 1) self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 1.0) - self.assertEqual(scores.correct_segments[1], (5, 5)) - self.assertEqual(scores.correct_segments[2], (2, 3)) - self.assertEqual(scores.correct_segments[3], (0, 1)) + self.assertEqual(scores.correct_segments["1"], (5, 5)) + self.assertEqual(scores.correct_segments["2"], (2, 3)) + self.assertEqual(scores.correct_segments["3"], (0, 1)) self.delete_db() def test_fp_division_evaluation_at_beginning_of_gt(self): @@ -271,8 +286,10 @@ def test_fp_division_evaluation_at_beginning_of_gt(self): edges.append((5, 1)) rec_track_graph = self.create_graph(cells, edges, roi) scores = e.evaluate( - gt_track_graph, rec_track_graph, matching_threshold=2, - sparse=True) + gt_track_graph, rec_track_graph, matching_threshold=2, + sparse=True, validation_score=None, window_size=5, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True) self.assertEqual(scores.matched_edges, 3) self.assertEqual(scores.fp_edges, 0) @@ -339,8 +356,10 @@ def test_one_off_fp_division_evaluation(self): cell[1]['y'] += 1 rec_track_graph = self.create_graph(cells, edges, roi) scores = e.evaluate( - gt_track_graph, rec_track_graph, matching_threshold=2, - sparse=True) + gt_track_graph, rec_track_graph, matching_threshold=2, + sparse=True, validation_score=None, window_size=5, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True) self.assertEqual(scores.identity_switches, 0) self.assertEqual(scores.gt_divisions, 2) @@ -402,8 +421,10 @@ def test_one_off_fp_division_evaluation2(self): cell[1]['y'] += 1 rec_track_graph = self.create_graph(cells, edges, roi) scores = e.evaluate( - gt_track_graph, rec_track_graph, matching_threshold=2, - sparse=True) + gt_track_graph, rec_track_graph, matching_threshold=2, + sparse=True, validation_score=None, window_size=5, + ignore_one_off_div_errors=False, + fn_div_count_unconnected_parent=True) self.assertEqual(scores.identity_switches, 1) self.assertEqual(scores.gt_divisions, 2) diff --git a/tests/test_greedy_baseline.py b/tests/test_greedy_baseline.py index 875d29b..1b2bcd3 100644 --- a/tests/test_greedy_baseline.py +++ b/tests/test_greedy_baseline.py @@ -1,9 +1,11 @@ -import linajea.tracking import logging -import linajea +import pymongo import unittest + import daisy -import pymongo + +import linajea.utils +import linajea.tracking logging.basicConfig(level=logging.INFO) @@ -40,9 +42,10 @@ def test_greedy_basic(self): ] db_name = 'linajea_test_solver' db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) + graph_provider = linajea.utils.CandidateDatabase( + db_name, + db_host, + mode='w') roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) graph = graph_provider[roi] graph.add_nodes_from([(cell['id'], cell) for cell in cells]) @@ -92,9 +95,10 @@ def test_greedy_split(self): ] db_name = 'linajea_test_solver' db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) + graph_provider = linajea.utils.CandidateDatabase( + db_name, + db_host, + mode='w') roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) graph = graph_provider[roi] graph.add_nodes_from([(cell['id'], cell) for cell in cells]) @@ -111,7 +115,6 @@ def test_greedy_split(self): selected_edges.append((u, v)) expected_result = [ (1, 0), - (2, 1), (3, 1), (5, 3) ] @@ -145,9 +148,10 @@ def test_greedy_node_threshold(self): ] db_name = 'linajea_test_solver' db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) + graph_provider = linajea.utils.CandidateDatabase( + db_name, + db_host, + mode='w') roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) graph = graph_provider[roi] graph.add_nodes_from([(cell['id'], cell) for cell in cells]) @@ -165,7 +169,6 @@ def test_greedy_node_threshold(self): selected_edges.append((u, v)) expected_result = [ (1, 0), - (2, 1), (4, 1), (5, 4) ] diff --git a/tests/test_minimal_solver.py b/tests/test_minimal_solver.py deleted file mode 100644 index 22909da..0000000 --- a/tests/test_minimal_solver.py +++ /dev/null @@ -1,444 +0,0 @@ -import linajea.tracking -import linajea.config -import logging -import linajea -import unittest -import daisy -import pymongo - -logging.basicConfig(level=logging.INFO) -# logging.getLogger('linajea.tracking').setLevel(logging.DEBUG) - -class TestTrackingConfig(): - def __init__(self, solve_config): - self.solve = solve_config - - -class TestSolver(unittest.TestCase): - - def delete_db(self, db_name, db_host): - client = pymongo.MongoClient(db_host) - client.drop_database(db_name) - - def test_solver_basic(self): - '''x - 3| /-4 - 2| /--3---5 - 1| 0---1 - 0| \\--2 - ------------------------------------ t - 0 1 2 3 - - Should select 0, 1, 2, 3, 5 - ''' - - cells = [ - {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0}, - {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0}, - {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0}, - {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0} - ] - - edges = [ - {'source': 1, 'target': 0, 'score': 1.0, - 'prediction_distance': 0.0}, - {'source': 2, 'target': 1, 'score': 1.0, - 'prediction_distance': 1.0}, - {'source': 3, 'target': 1, 'score': 1.0, - 'prediction_distance': 1.0}, - {'source': 4, 'target': 1, 'score': 1.0, - 'prediction_distance': 2.0}, - {'source': 5, 'target': 3, 'score': 1.0, - 'prediction_distance': 0.0}, - ] - db_name = 'linajea_test_solver' - db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) - roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) - graph = graph_provider[roi] - ps = { - "track_cost": 4.0, - "weight_edge_score": 0.1, - "weight_node_score": -0.1, - "selection_constant": -1.0, - "max_cell_move": 0.0, - "block_size": [5, 100, 100, 100], - "context": [2, 100, 100, 100], - } - job = {"num_workers": 5, "queue": "normal"} - solve_config = linajea.config.SolveConfig(parameters=ps, job=job) - config = TestTrackingConfig(solve_config) - - graph.add_nodes_from([(cell['id'], cell) for cell in cells]) - graph.add_edges_from([(edge['source'], edge['target'], edge) - for edge in edges]) - linajea.tracking.track( - graph, - config, - frame_key='t', - selected_key='selected') - - selected_edges = [] - for u, v, data in graph.edges(data=True): - if data['selected']: - selected_edges.append((u, v)) - expected_result = [ - (1, 0), - (2, 1), - (3, 1), - (5, 3) - ] - self.assertCountEqual(selected_edges, expected_result) - self.delete_db(db_name, db_host) - - def test_solver_node_close_to_edge(self): - # x - # 3| /-4 - # 2| /--3 - # 1| 0---1 - # 0| \--2 - # ------------------------------------ t - # 0 1 2 - - cells = [ - {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0}, - {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0}, - {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 4, 'score': 2.0} - ] - - edges = [ - {'source': 1, 'target': 0, 'score': 1.0, - 'prediction_distance': 0.0}, - {'source': 2, 'target': 1, 'score': 1.0, - 'prediction_distance': 1.0}, - {'source': 3, 'target': 1, 'score': 1.0, - 'prediction_distance': 1.0}, - {'source': 4, 'target': 1, 'score': 1.0, - 'prediction_distance': 2.0}, - ] - db_name = 'linajea_test_solver' - db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) - roi = daisy.Roi((0, 0, 0, 0), (5, 5, 5, 5)) - graph = graph_provider[roi] - ps = { - "track_cost": 4.0, - "weight_edge_score": 0.1, - "weight_node_score": -0.1, - "selection_constant": -1.0, - "max_cell_move": 1.0, - "block_size": [5, 100, 100, 100], - "context": [2, 100, 100, 100], - } - parameters = linajea.config.SolveParametersMinimalConfig(**ps) - - graph.add_nodes_from([(cell['id'], cell) for cell in cells]) - graph.add_edges_from([(edge['source'], edge['target'], edge) - for edge in edges]) - track_graph = linajea.tracking.TrackGraph( - graph, frame_key='t', roi=graph.roi) - solver = linajea.tracking.Solver(track_graph, parameters, 'selected') - - for node, data in track_graph.nodes(data=True): - close = solver._check_node_close_to_roi_edge(node, data, 1) - if node in [2, 4]: - close = not close - self.assertFalse(close) - self.delete_db(db_name, db_host) - - def test_solver_multiple_configs(self): - # x - # 3| /-4 - # 2| /--3---5 - # 1| 0---1 - # 0| \--2 - # ------------------------------------ t - # 0 1 2 3 - - cells = [ - {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0}, - {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0}, - {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0}, - {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0} - ] - - edges = [ - {'source': 1, 'target': 0, 'score': 1.0, - 'prediction_distance': 0.0}, - {'source': 2, 'target': 1, 'score': 1.0, - 'prediction_distance': 1.0}, - {'source': 3, 'target': 1, 'score': 1.0, - 'prediction_distance': 1.0}, - {'source': 4, 'target': 1, 'score': 1.0, - 'prediction_distance': 2.0}, - {'source': 5, 'target': 3, 'score': 1.0, - 'prediction_distance': 0.0}, - ] - db_name = 'linajea_test_solver' - db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) - roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) - graph = graph_provider[roi] - ps1 = { - "track_cost": 4.0, - "weight_edge_score": 0.1, - "weight_node_score": -0.1, - "selection_constant": -1.0, - "max_cell_move": 0.0, - "block_size": [5, 100, 100, 100], - "context": [2, 100, 100, 100], - } - ps2 = { - # Making all the values smaller increases the - # relative cost of division - "track_cost": 1.0, - "weight_edge_score": 0.01, - "weight_node_score": -0.01, - "selection_constant": -0.1, - "max_cell_move": 0.0, - "block_size": [5, 100, 100, 100], - "context": [2, 100, 100, 100], - } - parameters = [ps1, ps2] - keys = ['selected_1', 'selected_2'] - job = {"num_workers": 5, "queue": "normal"} - solve_config = linajea.config.SolveConfig(parameters=parameters, job=job) - config = TestTrackingConfig(solve_config) - - graph.add_nodes_from([(cell['id'], cell) for cell in cells]) - graph.add_edges_from([(edge['source'], edge['target'], edge) - for edge in edges]) - linajea.tracking.track( - graph, - config, - frame_key='t', - selected_key=keys) - - selected_edges_1 = [] - selected_edges_2 = [] - for u, v, data in graph.edges(data=True): - if data['selected_1']: - selected_edges_1.append((u, v)) - if data['selected_2']: - selected_edges_2.append((u, v)) - expected_result_1 = [ - (1, 0), - (2, 1), - (3, 1), - (5, 3) - ] - expected_result_2 = [ - (1, 0), - (3, 1), - (5, 3) - ] - self.assertCountEqual(selected_edges_1, expected_result_1) - self.assertCountEqual(selected_edges_2, expected_result_2) - self.delete_db(db_name, db_host) - - def test_solver_cell_cycle(self): - '''x - 3| /-4 - 2| /--3---5 - 1| 0---1 - 0| \\--2 - ------------------------------------ t - 0 1 2 3 - - Should select 0, 1, 2, 3, 5 - ''' - - cells = [ - {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 0, - "vgg_scorenormal": 1}, - {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, - 'vgg_scoremother': 1, - "vgg_scoredaughter": 0, - "vgg_scorenormal": 0}, - {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 1, - "vgg_scorenormal": 0}, - {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 1, - "vgg_scorenormal": 0}, - {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 0, - "vgg_scorenormal": 1}, - {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 0, - "vgg_scorenormal": 1} - ] - - edges = [ - {'source': 1, 'target': 0, 'score': 1.0, - 'prediction_distance': 0.0}, - {'source': 2, 'target': 1, 'score': 1.0, - 'prediction_distance': 1.0}, - {'source': 3, 'target': 1, 'score': 1.0, - 'prediction_distance': 1.0}, - {'source': 4, 'target': 1, 'score': 1.0, - 'prediction_distance': 2.0}, - {'source': 5, 'target': 3, 'score': 1.0, - 'prediction_distance': 0.0}, - ] - db_name = 'linajea_test_solver' - db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) - roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) - graph = graph_provider[roi] - ps = { - "track_cost": 4.0, - "weight_edge_score": 0.1, - "weight_node_score": -0.1, - "selection_constant": -1.0, - "weight_division": -0.1, - "weight_child": -0.1, - "weight_continuation": -0.1, - "division_constant": 1, - "max_cell_move": 0.0, - "block_size": [5, 100, 100, 100], - "context": [2, 100, 100, 100], - "cell_cycle_key": "vgg_score", - } - job = {"num_workers": 5, "queue": "normal"} - solve_config = linajea.config.SolveConfig(parameters=ps, job=job) - config = TestTrackingConfig(solve_config) - - graph.add_nodes_from([(cell['id'], cell) for cell in cells]) - graph.add_edges_from([(edge['source'], edge['target'], edge) - for edge in edges]) - linajea.tracking.track( - graph, - config, - frame_key='t', - selected_key='selected') - - selected_edges = [] - for u, v, data in graph.edges(data=True): - if data['selected']: - selected_edges.append((u, v)) - expected_result = [ - (1, 0), - (2, 1), - (3, 1), - (5, 3) - ] - self.assertCountEqual(selected_edges, expected_result) - self.delete_db(db_name, db_host) - - def test_solver_cell_cycle2(self): - '''x - 3| /-4 - 2| /--3---5 - 1| 0---1 - 0| \\--2 - ------------------------------------ t - 0 1 2 3 - - Should select 0, 1, 3, 5 due to vgg predicting continuation - ''' - - cells = [ - {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 0, - "vgg_scorenormal": 1}, - {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 0, - "vgg_scorenormal": 1}, - {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 0, - "vgg_scorenormal": 1}, - {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 0, - "vgg_scorenormal": 1}, - {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 0, - "vgg_scorenormal": 1}, - {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, - 'vgg_scoremother': 0, - "vgg_scoredaughter": 0, - "vgg_scorenormal": 1}, - ] - - edges = [ - {'source': 1, 'target': 0, 'score': 1.0, - 'prediction_distance': 0.0}, - {'source': 2, 'target': 1, 'score': 1.0, - 'prediction_distance': 1.0}, - {'source': 3, 'target': 1, 'score': 1.0, - 'prediction_distance': 1.0}, - {'source': 4, 'target': 1, 'score': 1.0, - 'prediction_distance': 2.0}, - {'source': 5, 'target': 3, 'score': 1.0, - 'prediction_distance': 0.0}, - ] - db_name = 'linajea_test_solver' - db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) - roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) - graph = graph_provider[roi] - ps = { - "track_cost": 4.0, - "weight_edge_score": 0.1, - "weight_node_score": -0.1, - "selection_constant": 0.0, - "weight_division": -0.1, - "weight_child": -0.1, - "weight_continuation": -0.1, - "division_constant": 1, - "max_cell_move": 0.0, - "block_size": [5, 100, 100, 100], - "context": [2, 100, 100, 100], - "cell_cycle_key": "vgg_score" - } - job = {"num_workers": 5, "queue": "normal"} - solve_config = linajea.config.SolveConfig(parameters=ps, job=job) - config = TestTrackingConfig(solve_config) - - graph.add_nodes_from([(cell['id'], cell) for cell in cells]) - graph.add_edges_from([(edge['source'], edge['target'], edge) - for edge in edges]) - linajea.tracking.track( - graph, - config, - frame_key='t', - selected_key='selected') - - selected_edges = [] - for u, v, data in graph.edges(data=True): - if data['selected']: - selected_edges.append((u, v)) - expected_result = [ - (1, 0), - (3, 1), - (5, 3) - ] - self.assertCountEqual(selected_edges, expected_result) - self.delete_db(db_name, db_host) diff --git a/tests/test_non_minimal_solver.py b/tests/test_non_minimal_solver.py deleted file mode 100644 index 6070bd7..0000000 --- a/tests/test_non_minimal_solver.py +++ /dev/null @@ -1,243 +0,0 @@ -import linajea.tracking -import logging -import linajea -import unittest -import daisy -import pymongo - -logging.basicConfig(level=logging.INFO) -# logging.getLogger('linajea.tracking').setLevel(logging.DEBUG) - - -class TestTrackingConfig(): - def __init__(self, solve_config): - self.solve = solve_config - - -class TestSolver(unittest.TestCase): - - def delete_db(self, db_name, db_host): - client = pymongo.MongoClient(db_host) - client.drop_database(db_name) - - def test_solver_basic(self): - # x - # 3| /-4 - # 2| /--3---5 - # 1| 0---1 - # 0| \--2 - # ------------------------------------ t - # 0 1 2 3 - - cells = [ - {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0}, - {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0}, - {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0}, - {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0} - ] - - edges = [ - {'source': 1, 'target': 0, 'score': 1.0, 'prediction_distance': 0.0}, - {'source': 2, 'target': 1, 'score': 1.0, 'prediction_distance': 1.0}, - {'source': 3, 'target': 1, 'score': 1.0, 'prediction_distance': 1.0}, - {'source': 4, 'target': 1, 'score': 1.0, 'prediction_distance': 2.0}, - {'source': 5, 'target': 3, 'score': 1.0, 'prediction_distance': 0.0}, - ] - db_name = 'linajea_test_solver' - db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) - roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) - graph = graph_provider[roi] - ps = { - "cost_appear": 2.0, - "cost_disappear": 2.0, - "cost_split": 0, - "weight_prediction_distance_cost": 0.1, - "weight_node_score": 1.0, - "threshold_node_score": 0.0, - "threshold_edge_score": 2.0, - "max_cell_move": 0.0, - "block_size": [5, 100, 100, 100], - "context": [2, 100, 100, 100], - } - job = {"num_workers": 5, "queue": "normal"} - solve_config = linajea.config.SolveConfig(parameters=ps, job=job) - config = TestTrackingConfig(solve_config) - - graph.add_nodes_from([(cell['id'], cell) for cell in cells]) - graph.add_edges_from([(edge['source'], edge['target'], edge) - for edge in edges]) - linajea.tracking.nm_track( - graph, - config, - frame_key='t', - selected_key='selected') - - selected_edges = [] - for u, v, data in graph.edges(data=True): - if data['selected']: - selected_edges.append((u, v)) - expected_result = [ - (1, 0), - (2, 1), - (3, 1), - (5, 3) - ] - self.assertCountEqual(selected_edges, expected_result) - self.delete_db(db_name, db_host) - - def test_solver_node_close_to_edge(self): - # x - # 3| /-4 - # 2| /--3 - # 1| 0---1 - # 0| \--2 - # ------------------------------------ t - # 0 1 2 - - cells = [ - {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0}, - {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0}, - {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 4, 'score': 2.0} - ] - - edges = [ - {'source': 1, 'target': 0, 'score': 1.0, 'prediction_distance': 0.0}, - {'source': 2, 'target': 1, 'score': 1.0, 'prediction_distance': 1.0}, - {'source': 3, 'target': 1, 'score': 1.0, 'prediction_distance': 1.0}, - {'source': 4, 'target': 1, 'score': 1.0, 'prediction_distance': 2.0}, - ] - db_name = 'linajea_test_solver' - db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) - roi = daisy.Roi((0, 0, 0, 0), (5, 5, 5, 5)) - graph = graph_provider[roi] - ps = { - "cost_appear": 1.0, - "cost_disappear": 1.0, - "cost_split": 0, - "weight_prediction_distance_cost": 0.1, - "weight_node_score": 1.0, - "threshold_node_score": 0.0, - "threshold_edge_score": 0.0, - "max_cell_move": 1.0, - "block_size": [5, 100, 100, 100], - "context": [2, 100, 100, 100], - } - parameters = linajea.config.SolveParametersNonMinimalConfig(**ps) - - graph.add_nodes_from([(cell['id'], cell) for cell in cells]) - graph.add_edges_from([(edge['source'], edge['target'], edge) - for edge in edges]) - track_graph = linajea.tracking.TrackGraph( - graph, frame_key='t', roi=graph.roi) - solver = linajea.tracking.NMSolver(track_graph, parameters, 'selected') - - for node, data in track_graph.nodes(data=True): - close = solver._check_node_close_to_roi_edge(node, data, 1) - if node in [2, 4]: - close = not close - self.assertFalse(close) - self.delete_db(db_name, db_host) - - - def test_solver_multiple_configs(self): - # x - # 3| /-4 - # 2| /--3---5 - # 1| 0---1 - # 0| \--2 - # ------------------------------------ t - # 0 1 2 3 - - cells = [ - {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, - {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0}, - {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0}, - {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0}, - {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0} - ] - - edges = [ - {'source': 1, 'target': 0, 'score': 1.0, 'prediction_distance': 0.0}, - {'source': 2, 'target': 1, 'score': 1.0, 'prediction_distance': 1.0}, - {'source': 3, 'target': 1, 'score': 1.0, 'prediction_distance': 1.0}, - {'source': 4, 'target': 1, 'score': 1.0, 'prediction_distance': 2.0}, - {'source': 5, 'target': 3, 'score': 1.0, 'prediction_distance': 0.0}, - ] - db_name = 'linajea_test_solver' - db_host = 'localhost' - graph_provider = linajea.CandidateDatabase( - db_name, - db_host) - roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) - graph = graph_provider[roi] - ps1 = { - "cost_appear": 2.0, - "cost_disappear": 2.0, - "cost_split": 0, - "weight_prediction_distance_cost": 0.1, - "weight_node_score": 1.0, - "threshold_node_score": 0.0, - "threshold_edge_score": 2.0, - "max_cell_move": 0.0, - "block_size": [5, 100, 100, 100], - "context": [2, 100, 100, 100], - } - ps2 = { - "cost_appear": 2.0, - "cost_disappear": 2.0, - "cost_split": 10.0, - "weight_prediction_distance_cost": 0.1, - "weight_node_score": 1.0, - "threshold_node_score": 0.0, - "threshold_edge_score": 2.0, - "max_cell_move": 0.0, - "block_size": [5, 100, 100, 100], - "context": [2, 100, 100, 100], - } - parameters = [ps1, ps2] - keys = ['selected_1', 'selected_2'] - job = {"num_workers": 5, "queue": "normal"} - solve_config = linajea.config.SolveConfig(parameters=parameters, job=job) - config = TestTrackingConfig(solve_config) - - graph.add_nodes_from([(cell['id'], cell) for cell in cells]) - graph.add_edges_from([(edge['source'], edge['target'], edge) - for edge in edges]) - linajea.tracking.nm_track( - graph, - config, - frame_key='t', - selected_key=keys) - - selected_edges_1 = [] - selected_edges_2 = [] - for u, v, data in graph.edges(data=True): - if data['selected_1']: - selected_edges_1.append((u, v)) - if data['selected_2']: - selected_edges_2.append((u, v)) - expected_result_1 = [ - (1, 0), - (2, 1), - (3, 1), - (5, 3) - ] - expected_result_2 = [ - (1, 0), - (3, 1), - (5, 3) - ] - self.assertCountEqual(selected_edges_1, expected_result_1) - self.assertCountEqual(selected_edges_2, expected_result_2) - self.delete_db(db_name, db_host) diff --git a/tests/test_split_into_tracks.py b/tests/test_split_into_tracks.py index d735568..4330aee 100644 --- a/tests/test_split_into_tracks.py +++ b/tests/test_split_into_tracks.py @@ -1,14 +1,16 @@ import unittest import random + import networkx as nx -from linajea.evaluation.validation_metric import split_into_tracks + +from linajea.evaluation.validation_metric import _split_into_tracks class TestSplitIntoTracks(unittest.TestCase): def test_remove_unconnected_node(self): graph = self.create_division() - conn_components = split_into_tracks(graph) + conn_components = _split_into_tracks(graph) self.assertEqual(len(conn_components), 2) def create_division(self): diff --git a/tests/test_tracks_source.py b/tests/test_tracks_source.py index 28e471a..e9c3bd8 100644 --- a/tests/test_tracks_source.py +++ b/tests/test_tracks_source.py @@ -1,13 +1,17 @@ import logging -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) -logging.getLogger('linajea').setLevel(logging.DEBUG) -from linajea.gunpowder import TracksSource, AddParentVectors import os -import gunpowder as gp import unittest + import numpy as np +import gunpowder as gp + +from linajea.gunpowder_nodes import TracksSource, AddMovementVectors + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) +logging.getLogger('linajea').setLevel(logging.DEBUG) + TEST_FILE = 'testdata.txt' TEST_FILE_WITH_HEADER = 'testdata_with_header.txt' @@ -88,15 +92,15 @@ def test_csv_header(self): def test_delete_points_in_context(self): points = gp.GraphKey("POINTS") - pv_array = gp.ArrayKey("PARENT_VECTORS") + mv_array = gp.ArrayKey("MOVEMENT_VECTORS") mask = gp.ArrayKey("MASK") radius = [0.1, 0.1, 0.1, 0.1] ts = TracksSource( TEST_FILE, points) - apv = AddParentVectors( + amv = AddMovementVectors( points, - pv_array, + mv_array, mask, radius) request = gp.BatchRequest() @@ -104,7 +108,7 @@ def test_delete_points_in_context(self): points, gp.Coordinate((1, 4, 4, 4))) request.add( - pv_array, + mv_array, gp.Coordinate((1, 4, 4, 4))) request.add( mask, @@ -113,21 +117,21 @@ def test_delete_points_in_context(self): pipeline = ( ts + gp.Pad(points, None) + - apv) + amv) with gp.build(pipeline): pipeline.request_batch(request) - def test_add_parent_vectors(self): + def test_add_movement_vectors(self): points = gp.GraphKey("POINTS") - pv_array = gp.ArrayKey("PARENT_VECTORS") + mv_array = gp.ArrayKey("MOVEMENT_VECTORS") mask = gp.ArrayKey("MASK") radius = [0.1, 0.1, 0.1, 0.1] ts = TracksSource( TEST_FILE, points) - apv = AddParentVectors( + amv = AddMovementVectors( points, - pv_array, + mv_array, mask, radius) request = gp.BatchRequest() @@ -135,7 +139,7 @@ def test_add_parent_vectors(self): points, gp.Coordinate((3, 4, 4, 4))) request.add( - pv_array, + mv_array, gp.Coordinate((1, 4, 4, 4))) request.add( mask, @@ -144,7 +148,7 @@ def test_add_parent_vectors(self): pipeline = ( ts + gp.Pad(points, None) + - apv) + amv) with gp.build(pipeline): batch = pipeline.request_batch(request) @@ -153,22 +157,22 @@ def test_add_parent_vectors(self): expected_mask[0, 0, 0, 0] = 1 expected_mask[0, 1, 2, 3] = 1 - expected_parent_vectors_z = np.zeros(shape=(1, 4, 4, 4)) - expected_parent_vectors_z[0, 1, 2, 3] = -1.0 + expected_movement_vectors_z = np.zeros(shape=(1, 4, 4, 4)) + expected_movement_vectors_z[0, 1, 2, 3] = -1.0 - expected_parent_vectors_y = np.zeros(shape=(1, 4, 4, 4)) - expected_parent_vectors_y[0, 1, 2, 3] = -2.0 + expected_movement_vectors_y = np.zeros(shape=(1, 4, 4, 4)) + expected_movement_vectors_y[0, 1, 2, 3] = -2.0 - expected_parent_vectors_x = np.zeros(shape=(1, 4, 4, 4)) - expected_parent_vectors_x[0, 1, 2, 3] = -3.0 + expected_movement_vectors_x = np.zeros(shape=(1, 4, 4, 4)) + expected_movement_vectors_x[0, 1, 2, 3] = -3.0 # print("MASK") # print(batch[mask].data) self.assertListEqual(expected_mask.tolist(), batch[mask].data.tolist()) - parent_vectors = batch[pv_array].data - self.assertListEqual(expected_parent_vectors_z.tolist(), - parent_vectors[0].tolist()) - self.assertListEqual(expected_parent_vectors_y.tolist(), - parent_vectors[1].tolist()) - self.assertListEqual(expected_parent_vectors_x.tolist(), - parent_vectors[2].tolist()) + movement_vectors = batch[mv_array].data + self.assertListEqual(expected_movement_vectors_z.tolist(), + movement_vectors[0].tolist()) + self.assertListEqual(expected_movement_vectors_y.tolist(), + movement_vectors[1].tolist()) + self.assertListEqual(expected_movement_vectors_x.tolist(), + movement_vectors[2].tolist()) diff --git a/tests/test_validation_metric.py b/tests/test_validation_metric.py index 13d7583..1ceeb7a 100644 --- a/tests/test_validation_metric.py +++ b/tests/test_validation_metric.py @@ -1,9 +1,12 @@ -import unittest +import logging import random +import unittest + import networkx as nx + from linajea.evaluation.validation_metric import ( - track_distance, norm_distance, validation_score) -import logging + _track_distance, _norm_distance, validation_score) + logging.basicConfig(level=logging.INFO) @@ -18,7 +21,7 @@ def test_perfect(self): gt_track = self.create_track(length, seed=seed) rec_track = self.create_track(length, seed=seed) self.assertAlmostEqual( - track_distance(gt_track, rec_track), 0, + _track_distance(gt_track, rec_track), 0, places=self.tolerance_places) def test_empty_reconstruction(self): @@ -26,7 +29,7 @@ def test_empty_reconstruction(self): seed = 1 gt_track = self.create_track(length, seed=seed) rec_track = nx.DiGraph() - self.assertEqual(track_distance(gt_track, rec_track), length) + self.assertEqual(_track_distance(gt_track, rec_track), length) def test_one_off(self): length = 8 @@ -36,8 +39,8 @@ def test_one_off(self): for node_id in range(length): rec_track.nodes[node_id]['x'] += 1 self.assertAlmostEqual( - track_distance(gt_track, rec_track), - length * norm_distance(1), + _track_distance(gt_track, rec_track), + length * _norm_distance(1), places=self.tolerance_places) def test_far_away(self): @@ -48,7 +51,7 @@ def test_far_away(self): for node_id in range(length): rec_track.nodes[node_id]['x'] += 200 self.assertAlmostEqual( - track_distance(gt_track, rec_track), + _track_distance(gt_track, rec_track), length, places=self.tolerance_places) @@ -59,7 +62,7 @@ def test_missing_first_edge(self): rec_track = self.create_track(length, seed=seed) rec_track.remove_node(0) self.assertAlmostEqual( - track_distance(gt_track, rec_track), + _track_distance(gt_track, rec_track), 1, places=self.tolerance_places) @@ -69,7 +72,7 @@ def test_extra_edge(self): gt_track = self.create_track(length, seed=seed) rec_track = self.create_track(length + 1, seed=seed) self.assertAlmostEqual( - track_distance(gt_track, rec_track), + _track_distance(gt_track, rec_track), 1, places=self.tolerance_places) @@ -109,7 +112,7 @@ def test_choosing_closer(self): second_rec_track.nodes[node_id]['x'] += 5 self.assertAlmostEqual( validation_score(gt_track, rec_track), - length * norm_distance(1), + length * _norm_distance(1), places=self.tolerance_places) def test_choosing_continuous(self): @@ -127,7 +130,7 @@ def test_choosing_continuous(self): self.assertAlmostEqual( validation_score(gt_track, rec_track), min(shorter_segment_len, - length * norm_distance(10)), + length * _norm_distance(10)), places=self.tolerance_places) # Multiple GT, One Rec @@ -157,7 +160,7 @@ def test_reusing_rec(self): gt_track = nx.union(gt_track, second_gt_track) self.assertAlmostEqual( validation_score(gt_track, rec_track), - length * norm_distance(10), + length * _norm_distance(10), places=self.tolerance_places) # track creation helper diff --git a/tests/test_write_cells.py b/tests/test_write_cells.py index d457241..c20c3a4 100644 --- a/tests/test_write_cells.py +++ b/tests/test_write_cells.py @@ -1,8 +1,9 @@ -from linajea.gunpowder import WriteCells import logging import unittest + import numpy as np +from linajea.gunpowder_nodes import WriteCells try: import absl.logging @@ -17,18 +18,18 @@ class WriteCellsTestCase(unittest.TestCase): - def get_parent_vectors(self): + def get_movement_vectors(self): # zyx offset channels, t, z, y, x a = np.arange(3*3*3*3, dtype=np.float32).reshape((3, 1, 3, 3, 3)) return a - def test_get_avg_pv(self): - parent_vectors = self.get_parent_vectors() - print(parent_vectors) + def test_get_avg_mv(self): + movement_vectors = self.get_movement_vectors() + print(movement_vectors) index = (0, 1, 1, 1) - self.assertEqual(WriteCells.get_avg_pv(parent_vectors, index, 1), + self.assertEqual(WriteCells.get_avg_mv(movement_vectors, index, 1), (13., 40., 67.)) - self.assertEqual(WriteCells.get_avg_pv(parent_vectors, index, 3), + self.assertEqual(WriteCells.get_avg_mv(movement_vectors, index, 3), (13., 40., 67.)) - self.assertEqual(WriteCells.get_avg_pv(parent_vectors, index, 5), + self.assertEqual(WriteCells.get_avg_mv(movement_vectors, index, 5), (13., 40., 67.)) From effc2223e8dc63294b16395877fa01b2628e8123 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 5 Jul 2022 06:56:13 -0400 Subject: [PATCH 208/263] refactor solver two preset solver versions available: - basic (nbt paper) - cell_state (miccai paper) (choose by setting config.solve.solver_type) if not set, user has to supply in init function: - list of types of indicators (e.g. node_selected) for nodes (node_indicator_keys) and edges (edge_indicator_keys) - lists of types of constraints - pin constraints (pin_constraints_fn_list) - edge constraints (edge_constraints_fn_list) - node constraints (node_constraints_fn_list) - inter-frame constraints (inter_frame_constraints_fn_list) user has to set objective by calling update_objective and has to supply - dict of indicator type to cost function to call for each indicator of that type - for both nodes and edges (node_indicator_fn_map and edge_indicator_fn_map) --- linajea/config/solve.py | 19 + linajea/config/utils.py | 2 + linajea/evaluation/evaluate_setup.py | 39 +- linajea/process_blockwise/solve_blockwise.py | 8 +- linajea/run_scripts/04_solve.py | 2 +- linajea/run_scripts/05_evaluate.py | 2 +- .../config_example_single_sample_model.toml | 2 - .../config_example_single_sample_predict.toml | 4 +- .../config_example_single_sample_test.toml | 1 - .../config_example_single_sample_train.toml | 9 +- .../config_example_single_sample_val.toml | 1 - linajea/tracking/__init__.py | 6 +- linajea/tracking/constraints.py | 205 ++++++++++ linajea/tracking/cost_functions.py | 154 ++++++++ linajea/tracking/solver.py | 356 +++++++----------- linajea/tracking/track.py | 184 ++++++++- linajea/utils/candidate_database.py | 8 +- linajea/utils/get_next_inference_data.py | 5 +- 18 files changed, 717 insertions(+), 290 deletions(-) create mode 100644 linajea/tracking/constraints.py create mode 100644 linajea/tracking/cost_functions.py diff --git a/linajea/config/solve.py b/linajea/config/solve.py index d9e7a6f..eeac36d 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -27,6 +27,8 @@ class SolveParametersConfig: division_constant, weight_child, weight_continuation, weight_edge_score: float main ILP hyperparameters + cell_state_key: str + key defining which cell state classifier to use block_size: list of int ILP is solved in blocks, defines size of each block context: list of int @@ -60,6 +62,7 @@ class SolveParametersConfig: weight_child = attr.ib(type=float, default=0.0) weight_continuation = attr.ib(type=float, default=0.0) weight_edge_score = attr.ib(type=float) + cell_state_key = attr.ib(type=str, default=None) block_size = attr.ib(type=Tuple[int, int, int, int]) context = attr.ib(type=Tuple[int, int, int, int]) max_cell_move = attr.ib(type=int, default=None) @@ -123,6 +126,7 @@ class SolveParametersSearchConfig: weight_child = attr.ib(type=List[float], default=None) weight_continuation = attr.ib(type=List[float], default=None) weight_edge_score = attr.ib(type=List[float]) + cell_state_key = attr.ib(type=str, default=None) block_size = attr.ib(type=List[List[int]]) context = attr.ib(type=List[List[int]]) max_cell_move = attr.ib(type=List[int], default=None) @@ -205,6 +209,12 @@ def write_solve_parameters_configs(parameters_search, grid): conf[k] = value search_configs.append(conf) else: + if params.get('cell_state_key') == '': + params['cell_state_key'] = None + elif isinstance(params.get('cell_state_key'), list) and \ + '' in params['cell_state_key']: + params['cell_state_key'] = [k if k != '' else None + for k in params['cell_state_key']] search_configs = [ dict(zip(params.keys(), x)) for x in itertools.product(*params.values())] @@ -250,6 +260,10 @@ class SolveConfig: grid_search, random_search: bool If grid and/or random search over ILP parameters should be performed + solver_type: str + Select preset type of Solver (set of constraints, indicators and + cost functions), if None those have to be defined by calling + function. Current options: `basic` and `cell_state` Notes ----- @@ -267,11 +281,16 @@ class SolveConfig: parameters_search_random = attr.ib( converter=ensure_cls(SolveParametersSearchConfig), default=None) greedy = attr.ib(type=bool, default=False) + write_struct_svm = attr.ib(type=str, default=None) check_node_close_to_roi = attr.ib(type=bool, default=True) timeout = attr.ib(type=int, default=120) clip_low_score = attr.ib(type=float, default=None) grid_search = attr.ib(type=bool, default=False) random_search = attr.ib(type=bool, default=False) + solver_type = attr.ib(type=str, default=None, + validator=attr.validators.optional(attr.validators.in_([ + "basic", + "cell_state"]))) def __attrs_post_init__(self): assert self.parameters is not None or \ diff --git a/linajea/config/utils.py b/linajea/config/utils.py index bdcb523..0d405ff 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -99,6 +99,8 @@ def converter(vals): if isinstance(vals, str) and vals.endswith(".toml"): vals = load_config(vals) + assert len(vals) == 1, "expects dict with single entry" + vals = list(vals.values())[0] if not isinstance(vals, list): vals = [vals] diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index 61df411..403dc8d 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -6,6 +6,7 @@ import os import time +import networkx as nx import daisy import linajea.tracking @@ -65,8 +66,8 @@ def evaluate_setup(linajea_config): old_score = results_db.get_score(parameters_id, linajea_config.evaluate.parameters) if old_score: - logger.info("Already evaluated %d (%s). Skipping" % - (parameters_id, linajea_config.evaluate.parameters)) + logger.info("Already evaluated %d (%s). Skipping", + parameters_id, linajea_config.evaluate.parameters) score = {} for k, v in old_score.items(): if not isinstance(k, list) or k != "roi": @@ -83,19 +84,19 @@ def evaluate_setup(linajea_config): edges_db = linajea.utils.CandidateDatabase(db_name, db_host, parameters_id=parameters_id) - logger.info("Reading cells and edges in db %s with parameter_id %d" - % (db_name, parameters_id)) + logger.info("Reading cells and edges in db %s with parameter_id %d", + db_name, parameters_id) start_time = time.time() subgraph = edges_db.get_selected_graph(evaluate_roi) - logger.info("Read %d cells and %d edges in %s seconds" - % (subgraph.number_of_nodes(), - subgraph.number_of_edges(), - time.time() - start_time)) + logger.info("Read %d cells and %d edges in %s seconds", + subgraph.number_of_nodes(), + subgraph.number_of_edges(), + time.time() - start_time) if subgraph.number_of_edges() == 0: - logger.warn("No selected edges for parameters_id %d. Skipping" - % parameters_id) + logger.warn("No selected edges for parameters_id %d. Skipping", + parameters_id) return False track_graph = linajea.tracking.TrackGraph( @@ -104,21 +105,21 @@ def evaluate_setup(linajea_config): gt_db = linajea.utils.CandidateDatabase( linajea_config.inference_data.data_source.gt_db_name, db_host) - logger.info("Reading ground truth cells and edges in db %s" - % linajea_config.inference_data.data_source.gt_db_name) + logger.info("Reading ground truth cells and edges in db %s", + linajea_config.inference_data.data_source.gt_db_name) start_time = time.time() gt_subgraph = gt_db.get_graph( evaluate_roi, ) - logger.info("Read %d cells and %d edges in %s seconds" - % (gt_subgraph.number_of_nodes(), - gt_subgraph.number_of_edges(), - time.time() - start_time)) + logger.info("Read %d cells and %d edges in %s seconds", + gt_subgraph.number_of_nodes(), + gt_subgraph.number_of_edges(), + time.time() - start_time) gt_track_graph = linajea.tracking.TrackGraph( gt_subgraph, frame_key='t', roi=gt_subgraph.roi) - logger.info("Matching edges for parameters with id %d" % parameters_id) + logger.info("Matching edges for parameters with id %d", parameters_id) matching_threshold = linajea_config.evaluate.parameters.matching_threshold validation_score = linajea_config.evaluate.parameters.validation_score ignore_one_off_div_errors = \ @@ -137,8 +138,8 @@ def evaluate_setup(linajea_config): ignore_one_off_div_errors, fn_div_count_unconnected_parent) - logger.info("Done evaluating results for %d. Saving results to mongo." - % parameters_id) + logger.info("Done evaluating results for %d. Saving results to mongo.", + parameters_id) logger.debug("Result summary: %s", report.get_short_report()) results_db.write_score(parameters_id, report, eval_params=linajea_config.evaluate.parameters) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index ccb701c..eba77e7 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -181,13 +181,15 @@ def solve_in_block(linajea_config, db_name, db_host, mode='r+') + parameters = graph_provider.get_parameters(parameters_id[0]) start_time = time.time() selected_keys = ['selected_' + str(pid) for pid in parameters_id] edge_attrs = selected_keys.copy() edge_attrs.extend(["prediction_distance", "distance"]) graph = graph_provider.get_graph( read_roi, - edge_attrs=edge_attrs + edge_attrs=edge_attrs, + join_collection=parameters["cell_state_key"] ) # remove dangling nodes and edges @@ -218,14 +220,12 @@ def solve_in_block(linajea_config, write_done(block, step_name, db_name, db_host) return 0 - frames = [read_roi.get_offset()[0], - read_roi.get_offset()[0] + read_roi.get_shape()[0]] if linajea_config.solve.greedy: greedy_track(graph=graph, selected_key=selected_keys[0], node_threshold=0.2) else: track(graph, linajea_config, selected_keys, - frames=frames, block_id=block.block_id[1]) + block_id=block.block_id[1]) start_time = time.time() graph.update_edge_attrs( diff --git a/linajea/run_scripts/04_solve.py b/linajea/run_scripts/04_solve.py index 8538ca5..af3099f 100644 --- a/linajea/run_scripts/04_solve.py +++ b/linajea/run_scripts/04_solve.py @@ -37,7 +37,7 @@ help='get test parameters from validation parameters_id') parser.add_argument('--param_id', type=int, default=None, help='process parameters with parameters_id (e.g. resolve set of parameters)') - parser.add_argument('--param_ids', default=None, nargs=+, + parser.add_argument('--param_ids', default=None, nargs="+", help='start/end range or list of eval parameters_ids') parser.add_argument('--param_list_idx', type=str, default=None, help='only solve idx parameter set in config') diff --git a/linajea/run_scripts/05_evaluate.py b/linajea/run_scripts/05_evaluate.py index 1a42848..9739d11 100644 --- a/linajea/run_scripts/05_evaluate.py +++ b/linajea/run_scripts/05_evaluate.py @@ -38,7 +38,7 @@ help='get test parameters from validation parameters_id') parser.add_argument('--param_id', default=None, help='process parameters with parameters_id') - parser.add_argument('--param_ids', default=None, nargs=+, + parser.add_argument('--param_ids', default=None, nargs="+", help='start/end range or list of eval parameters_ids') parser.add_argument('--param_list_idx', type=str, default=None, help='only eval parameters[idx] in config') diff --git a/linajea/run_scripts/config_example_single_sample_model.toml b/linajea/run_scripts/config_example_single_sample_model.toml index 55f5a03..2fd765d 100644 --- a/linajea/run_scripts/config_example_single_sample_model.toml +++ b/linajea/run_scripts/config_example_single_sample_model.toml @@ -30,9 +30,7 @@ upsampling = "sep_transposed_conv" average_vectors = false # nms_window_shape = [ 3, 11, 11,] nms_window_shape = [ 3, 9, 9,] -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/mknet_celegans.py" cell_indicator_weighted = 0.01 cell_indicator_cutoff = 0.01 # cell_indicator_weighted = true -latent_temp_conv = false train_only_cell_indicator = false diff --git a/linajea/run_scripts/config_example_single_sample_predict.toml b/linajea/run_scripts/config_example_single_sample_predict.toml index 4444953..ef3adc3 100644 --- a/linajea/run_scripts/config_example_single_sample_predict.toml +++ b/linajea/run_scripts/config_example_single_sample_predict.toml @@ -1,6 +1,4 @@ -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/predict_celegans_torch.py" -path_to_script_db_from_zarr = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/write_cells_celegans.py" -output_zarr_prefix = "/nrs/funke/hirschp/linajea_experiments" +output_zarr_dir = "/nrs/funke/hirschp/linajea_experiments" write_to_zarr = false write_to_db = true write_db_from_zarr = false diff --git a/linajea/run_scripts/config_example_single_sample_test.toml b/linajea/run_scripts/config_example_single_sample_test.toml index 9f684a9..31a2a63 100644 --- a/linajea/run_scripts/config_example_single_sample_test.toml +++ b/linajea/run_scripts/config_example_single_sample_test.toml @@ -46,5 +46,4 @@ queue = "local" [evaluate.parameters] matching_threshold = 15 -sparse = false diff --git a/linajea/run_scripts/config_example_single_sample_train.toml b/linajea/run_scripts/config_example_single_sample_train.toml index 03a53fe..3c16ee1 100644 --- a/linajea/run_scripts/config_example_single_sample_train.toml +++ b/linajea/run_scripts/config_example_single_sample_train.toml @@ -48,27 +48,24 @@ upsampling = "sep_transposed_conv" average_vectors = false # nms_window_shape = [ 3, 11, 11,] nms_window_shape = [ 3, 9, 9,] -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/mknet_celegans.py" cell_indicator_weighted = 0.01 cell_indicator_cutoff = 0.01 # cell_indicator_weighted = true -latent_temp_conv = false train_only_cell_indicator = false [train] # val_log_step = 25 # radius for binary map -> *2 (in world units) # in which to draw parent vectors (not used if use_radius) -parent_radius = [ 0.1, 8.0, 8.0, 8.0,] +object_radius = [ 0.1, 8.0, 8.0, 8.0,] # upper bound for dist cell moved between two frames (needed for context) move_radius = 25 # sigma for Gauss -> ~*4 (5 in z -> in 3 slices) (not used if use_radius) rasterize_radius = [ 0.1, 5.0, 3.0, 3.0,] cache_size = 1 -parent_vectors_loss_transition_offset = 20000 -parent_vectors_loss_transition_factor = 0.001 +movement_vectors_loss_transition_offset = 20000 +movement_vectors_loss_transition_factor = 0.001 #use_radius = true -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/celegans_setups/train_val_celegans_torch.py" max_iterations = 11 checkpoint_stride = 10 snapshot_stride = 5 diff --git a/linajea/run_scripts/config_example_single_sample_val.toml b/linajea/run_scripts/config_example_single_sample_val.toml index aac3d4f..3623298 100644 --- a/linajea/run_scripts/config_example_single_sample_val.toml +++ b/linajea/run_scripts/config_example_single_sample_val.toml @@ -47,5 +47,4 @@ queue = "local" [evaluate.parameters] matching_threshold = 15 -sparse = false diff --git a/linajea/tracking/__init__.py b/linajea/tracking/__init__.py index 4e13edc..3f1b078 100644 --- a/linajea/tracking/__init__.py +++ b/linajea/tracking/__init__.py @@ -1,5 +1,7 @@ # flake8: noqa -from .track import track +from .track import (get_edge_indicator_fn_map_default, + get_node_indicator_fn_map_default, + track) from .greedy_track import greedy_track from .track_graph import TrackGraph -from .solver import Solver +from .solver import Solver, BasicSolver, CellStateSolver diff --git a/linajea/tracking/constraints.py b/linajea/tracking/constraints.py new file mode 100644 index 0000000..00e7dd1 --- /dev/null +++ b/linajea/tracking/constraints.py @@ -0,0 +1,205 @@ +import pylp + + +def ensure_edge_endpoints(edge, indicators): + """if e is selected, u and v have to be selected + """ + u, v = edge + ind_e = indicators["edge_selected"][edge] + ind_u = indicators["node_selected"][u] + ind_v = indicators["node_selected"][v] + + constraint = pylp.LinearConstraint() + constraint.set_coefficient(ind_e, 2) + constraint.set_coefficient(ind_u, -1) + constraint.set_coefficient(ind_v, -1) + constraint.set_relation(pylp.Relation.LessEqual) + constraint.set_value(0) + + return [constraint] + + +def ensure_one_predecessor(node, indicators, graph, **kwargs): + """Every selected node has exactly one selected edge to the previous frame + This includes the special "appear" edge. + + sum(prev) - node = 0 # exactly one prev edge, + iff node selected + """ + pinned_edges = kwargs["pinned_edges"] + + constraint_prev = pylp.LinearConstraint() + + # all neighbors in previous frame + pinned_to_1 = [] + for edge in graph.prev_edges(node): + constraint_prev.set_coefficient(indicators["edge_selected"][edge], 1) + if edge in pinned_edges and pinned_edges[edge]: + pinned_to_1.append(edge) + if len(pinned_to_1) > 1: + raise RuntimeError( + "Node %d has more than one prev edge pinned: %s" + % (node, pinned_to_1)) + # plus "appear" + constraint_prev.set_coefficient(indicators["node_appear"][node], 1) + + #node + constraint_prev.set_coefficient(indicators["node_selected"][node], -1) + + # relation, value + constraint_prev.set_relation(pylp.Relation.Equal) + + constraint_prev.set_value(0) + + return [constraint_prev] + + +def ensure_at_most_two_successors(node, indicators, graph, **kwargs): + """Every selected node has zero to two selected edges to the next frame. + + sum(next) - 2*node <= 0 # at most two next edges + """ + constraint_next = pylp.LinearConstraint() + + for edge in graph.next_edges(node): + constraint_next.set_coefficient(indicators["edge_selected"][edge], 1) + + # node + constraint_next.set_coefficient(indicators["node_selected"][node], -2) + + # relation, value + constraint_next.set_relation(pylp.Relation.LessEqual) + + constraint_next.set_value(0) + + return [constraint_next] + + +def ensure_split_set_for_divs(node, indicators, graph, **kwargs): + """Ensure that the split indicator is set for every cell that splits + into two daughter cells. + I.e., each node with two forwards edges is a split node. + + Constraint 1 + sum(forward edges) - split <= 1 + sum(forward edges) > 1 => split == 1 + + Constraint 2 + sum(forward edges) - 2*split >= 0 + sum(forward edges) <= 1 => split == 0 + """ + constraint_1 = pylp.LinearConstraint() + constraint_2 = pylp.LinearConstraint() + + # sum(forward edges) + for edge in graph.next_edges(node): + constraint_1.set_coefficient(indicators["edge_selected"][edge], 1) + constraint_2.set_coefficient(indicators["edge_selected"][edge], 1) + + # -[2*]split + constraint_1.set_coefficient(indicators["node_split"][node], -1) + constraint_2.set_coefficient(indicators["node_split"][node], -2) + + constraint_1.set_relation(pylp.Relation.LessEqual) + constraint_2.set_relation(pylp.Relation.GreaterEqual) + + constraint_1.set_value(1) + constraint_2.set_value(0) + + return [constraint_1, constraint_2] + + +def ensure_pinned_edge(edge, indicators, selected): + """Ensure that if an edge has already been set by a neighboring block + its state stays consistent (pin/fix its state). + + Constraint: + If selected + selected(e) = 1 + else: + selected(e) = 0 + """ + ind_e = indicators["edge_selected"][edge] + constraint = pylp.LinearConstraint() + constraint.set_coefficient(ind_e, 1) + constraint.set_relation(pylp.Relation.Equal) + constraint.set_value(1 if selected else 0) + + return [constraint] + + +def ensure_one_state(node, indicators): + """Ensure that each selected node has exactly on state assigned to it + + Constraint: + split(n) + child(n) + continuation(n) = selected(n) + """ + constraint = pylp.LinearConstraint() + constraint.set_coefficient(indicators["node_split"][node], 1) + constraint.set_coefficient(indicators["node_child"][node], 1) + constraint.set_coefficient(indicators["node_continuation"][node], 1) + constraint.set_coefficient(indicators["node_selected"][node], -1) + constraint.set_relation(pylp.Relation.Equal) + constraint.set_value(0) + + return [constraint] + + +def ensure_split_child(edge, indicators): + """If an edge is selected, the split (division) and child indicators + are linked. Let e=(u,v) be an edge linking node u at time t + 1 to v + in time t. + + Constraints: + child(u) + selected(e) - split(v) <= 1 + split(v) + selected(e) - child(u) <= 1 + """ + u, v = edge + ind_e = indicators["edge_selected"][edge] + split_v = indicators["node_split"][v] + child_u = indicators["node_child"][u] + + constraint_1 = pylp.LinearConstraint() + constraint_1.set_coefficient(child_u, 1) + constraint_1.set_coefficient(ind_e, 1) + constraint_1.set_coefficient(split_v, -1) + constraint_1.set_relation(pylp.Relation.LessEqual) + constraint_1.set_value(1) + + constraint_2 = pylp.LinearConstraint() + constraint_2.set_coefficient(split_v, 1) + constraint_2.set_coefficient(ind_e, 1) + constraint_2.set_coefficient(child_u, -1) + constraint_2.set_relation(pylp.Relation.LessEqual) + constraint_2.set_value(1) + + return [constraint_1, constraint_2] + + +def get_constraints_default(config): + solver_type = config.solve.solver_type + + if solver_type == "basic": + pin_constraints_fn_list = [ensure_pinned_edge] + edge_constraints_fn_list = [ensure_edge_endpoints] + node_constraints_fn_list = [] + inter_frame_constraints_fn_list = [ + ensure_one_predecessor, + ensure_at_most_two_successors, + ensure_split_set_for_divs] + elif solver_type == "cell_state": + pin_constraints_fn_list = [ensure_pinned_edge] + edge_constraints_fn_list = [ensure_edge_endpoints, ensure_split_child] + node_constraints_fn_list = [ensure_one_state] + inter_frame_constraints_fn_list = [ + ensure_one_predecessor, + ensure_at_most_two_successors, + ensure_split_set_for_divs] + else: + raise RuntimeError("solver_type %s unknown for constraints", + solver_type) + + return (pin_constraints_fn_list, + edge_constraints_fn_list, + node_constraints_fn_list, + inter_frame_constraints_fn_list) diff --git a/linajea/tracking/cost_functions.py b/linajea/tracking/cost_functions.py new file mode 100644 index 0000000..01d2e53 --- /dev/null +++ b/linajea/tracking/cost_functions.py @@ -0,0 +1,154 @@ +"""Provides a set of cost functions to use in solver + +Should return a list of costs that will be applied to some indicator +""" +import logging + +import numpy as np + +logger = logging.getLogger(__name__) + +def score_times_weight_plus_th_costs_fn(weight, threshold, key="score", + feature_func=lambda x: x): + + def cost_fn(obj): + # feature_func(obj score) times a weight plus a threshold + score_costs = [feature_func(obj[key]) * weight, threshold] + logger.debug("set score times weight plus th costs %s", score_costs) + return score_costs + + return cost_fn + + +def score_times_weight_costs_fn(weight, key="score", + feature_func=lambda x: x): + + def cost_fn(obj): + # feature_func(obj score) times a weight + score_costs = [feature_func(obj[key]) * weight] + logger.debug("set score times weight costs %s", score_costs) + return score_costs + + return cost_fn + + +def constant_costs_fn(weight, zero_if_true=lambda _: False): + + def cost_fn(obj): + costs = [0] if zero_if_true(obj) else [weight] + logger.debug("set constant costs if %s = True costs %s", + zero_if_true(obj), costs) + return costs + + return cost_fn + + +def is_nth_frame(n, frame_key='t'): + + def is_frame(obj): + return obj[frame_key] == n + + return is_frame + + +def is_close_to_roi_border(roi, distance): + + def is_close(obj): + '''Return true if obj is within distance to the z,y,x edge + of the roi. Assumes 4D data with t,z,y,x''' + if isinstance(distance, dict): + dist = min(distance.values()) + else: + dist = distance + + begin = roi.get_begin()[1:] + end = roi.get_end()[1:] + for index, dim in enumerate(['z', 'y', 'x']): + node_dim = obj[dim] + begin_dim = begin[index] + end_dim = end[index] + if node_dim + dist >= end_dim or\ + node_dim - dist < begin_dim: + logger.debug("Obj %s with value %s in dimension %s " + "is within %s of range [%d, %d]", + obj, node_dim, dim, dist, + begin_dim, end_dim) + return True + logger.debug("Obj %s is not within %s to edge of roi %s", + obj, dist, roi) + return False + + return is_close + + +def get_node_indicator_fn_map_default(config, parameters, graph): + if parameters.feature_func == "noop": + feature_func = lambda x: x + elif parameters.feature_func == "log": + feature_func = np.log + elif parameters.feature_func == "square": + feature_func = np.square + else: + raise RuntimeError("unknown (non-linear) feature function: %s", + parameters.feature_func) + + solver_type = config.solve.solver_type + fn_map = { + "node_selected": + score_times_weight_plus_th_costs_fn( + parameters.weight_node_score, + parameters.selection_constant, + key="score", feature_func=feature_func), + "node_appear": constant_costs_fn( + parameters.track_cost, + zero_if_true=lambda obj: ( + is_nth_frame(graph.begin)(obj) or + (config.solve.check_node_close_to_roi and + is_close_to_roi_border( + graph.roi, parameters.max_cell_move)(obj)))) + } + if solver_type == "basic": + fn_map["node_split"] = constant_costs_fn(1) + elif solver_type == "cell_state": + fn_map["node_split"] = score_times_weight_plus_th_costs_fn( + parameters.weight_division, + parameters.division_constant, + key="score_mother", feature_func=feature_func) + fn_map["node_child"] = score_times_weight_costs_fn( + parameters.weight_child, + key="score_daughter", feature_func=feature_func) + fn_map["node_continuation"] = score_times_weight_costs_fn( + parameters.weight_continuation, + key="score_continuation", feature_func=feature_func) + else: + logger.info("solver_type %s unknown for node indicators, skipping", + solver_type) + + return fn_map + + +def get_edge_indicator_fn_map_default(config, parameters): + if parameters.feature_func == "noop": + feature_func = lambda x: x + elif parameters.feature_func == "log": + feature_func = np.log + elif parameters.feature_func == "square": + feature_func = np.square + else: + raise RuntimeError("unknown (non-linear) feature function: %s", + parameters.feature_func) + + solver_type = config.solve.solver_type + fn_map = { + "edge_selected": + score_times_weight_costs_fn(parameters.weight_edge_score, + key="prediction_distance", + feature_func=feature_func) + } + if solver_type == "basic": + pass + else: + logger.info("solver_type %s unknown for edge indicators, skipping", + solver_type) + + return fn_map diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index 2ce738f..ca52568 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -3,10 +3,16 @@ # -*- coding: utf-8 -*- import logging -import numpy as np - import pylp +from .constraints import (ensure_at_most_two_successors, + ensure_edge_endpoints, + ensure_one_predecessor, + ensure_pinned_edge, + ensure_split_set_for_divs, + ensure_one_state, + ensure_split_child) + logger = logging.getLogger(__name__) @@ -15,24 +21,15 @@ class Solver(object): Class for initializing and solving the ILP problem for creating tracks from candidate nodes and edges using pylp. ''' - def __init__(self, track_graph, parameters, selected_key, frames=None, - block_id=None, check_node_close_to_roi=True, timeout=120): - # frames: [start_frame, end_frame] where start_frame is inclusive - # and end_frame is exclusive. Defaults to track_graph.begin, - # track_graph.end - self.check_node_close_to_roi = check_node_close_to_roi - self.block_id = block_id + def __init__(self, track_graph, + node_indicator_keys, edge_indicator_keys, + pin_constraints_fn_list, edge_constraints_fn_list, + node_constraints_fn_list, inter_frame_constraints_fn_list, + timeout=120): self.graph = track_graph - self.start_frame = frames[0] if frames else self.graph.begin - self.end_frame = frames[1] if frames else self.graph.end self.timeout = timeout - self.node_selected = {} - self.edge_selected = {} - self.node_appear = {} - self.node_disappear = {} - self.node_split = {} self.pinned_edges = {} self.num_vars = None @@ -41,13 +38,26 @@ def __init__(self, track_graph, parameters, selected_key, frames=None, self.pin_constraints = [] # list of LinearConstraint objects self.solver = None + self.node_indicator_keys = set(node_indicator_keys) + self.edge_indicator_keys = set(edge_indicator_keys) + + self.pin_constraints_fn_list = pin_constraints_fn_list + self.edge_constraints_fn_list = edge_constraints_fn_list + self.node_constraints_fn_list = node_constraints_fn_list + self.inter_frame_constraints_fn_list = inter_frame_constraints_fn_list + self._create_indicators() self._create_solver() self._create_constraints() - self.update_objective(parameters, selected_key) - def update_objective(self, parameters, selected_key): - self.parameters = parameters + def update_objective(self, node_indicator_fn_map, edge_indicator_fn_map, + selected_key): + self.node_indicator_fn_map = node_indicator_fn_map + self.edge_indicator_fn_map = edge_indicator_fn_map + assert (set(self.node_indicator_fn_map.keys()) == self.node_indicator_keys and + set(self.edge_indicator_fn_map.keys()) == self.edge_indicator_keys), \ + "cannot change set of indicators during one run!" + self.selected_key = selected_key self._set_objective() @@ -74,33 +84,35 @@ def solve(self): logger.info(message) logger.info("costs of solution: %f", solution.get_value()) + return solution + + def solve_and_set(self, node_key="node_selected", edge_key="edge_selected"): + solution = self.solve() + for v in self.graph.nodes: self.graph.nodes[v][self.selected_key] = solution[ - self.node_selected[v]] > 0.5 + self.indicators[node_key][v]] > 0.5 for e in self.graph.edges: self.graph.edges[e][self.selected_key] = solution[ - self.edge_selected[e]] > 0.5 + self.indicators[edge_key][e]] > 0.5 def _create_indicators(self): + self.indicators = {} self.num_vars = 0 - # four indicators per node: - # 1. selected - # 2. appear - # 3. disappear - # 4. split - for node in self.graph.nodes: - self.node_selected[node] = self.num_vars - self.node_appear[node] = self.num_vars + 1 - self.node_disappear[node] = self.num_vars + 2 - self.node_split[node] = self.num_vars + 3 - self.num_vars += 6 + for k in self.node_indicator_keys: + self.indicators[k] = {} + for node in self.graph.nodes: + self.indicators[k][node] = self.num_vars + self.num_vars += 1 - for edge in self.graph.edges(): - self.edge_selected[edge] = self.num_vars - self.num_vars += 1 + for k in self.edge_indicator_keys: + self.indicators[k] = {} + for edge in self.graph.edges(): + self.indicators[k][edge] = self.num_vars + self.num_vars += 1 def _set_objective(self): @@ -108,89 +120,24 @@ def _set_objective(self): objective = pylp.LinearObjective(self.num_vars) - # node selection and cell cycle costs - for node in self.graph.nodes: - objective.set_coefficient( - self.node_selected[node], - self._node_costs(node)) - objective.set_coefficient( - self.node_split[node], 1) + # node costs + for k, fn in self.node_indicator_fn_map.items(): + for n_id, node in self.graph.nodes(data=True): + objective.set_coefficient(self.indicators[k][n_id], sum(fn(node))) - # edge selection costs - for edge in self.graph.edges(): - objective.set_coefficient( - self.edge_selected[edge], - self._edge_costs(edge)) - - # node appear (skip first frame) - for t in range(self.start_frame + 1, self.end_frame): - for node in self.graph.cells_by_frame(t): - objective.set_coefficient( - self.node_appear[node], - self.parameters.track_cost) - for node in self.graph.cells_by_frame(self.start_frame): - objective.set_coefficient( - self.node_appear[node], - 0) - - # remove node appear costs at edge of roi - if self.check_node_close_to_roi: - for node, data in self.graph.nodes(data=True): - if self._check_node_close_to_roi_edge( - node, - data, - self.parameters.max_cell_move): - objective.set_coefficient( - self.node_appear[node], - 0) + # edge costs + for k, fn in self.edge_indicator_fn_map.items(): + for u, v, edge in self.graph.edges(data=True): + objective.set_coefficient(self.indicators[k][(u, v)], sum(fn(edge))) self.objective = objective - def _check_node_close_to_roi_edge(self, node, data, distance): - '''Return true if node is within distance to the z,y,x edge - of the roi. Assumes 4D data with t,z,y,x''' - if isinstance(distance, dict): - distance = min(distance.values()) - - begin = self.graph.roi.get_begin()[1:] - end = self.graph.roi.get_end()[1:] - for index, dim in enumerate(['z', 'y', 'x']): - node_dim = data[dim] - begin_dim = begin[index] - end_dim = end[index] - if node_dim + distance >= end_dim or\ - node_dim - distance < begin_dim: - logger.debug("Node %d with value %s in dimension %s " - "is within %s of range [%d, %d]" % - (node, node_dim, dim, distance, - begin_dim, end_dim)) - return True - logger.debug("Node %d with position [%s, %s, %s] is not within " - "%s to edge of roi %s" % - (node, data['z'], data['y'], data['x'], distance, - self.graph.roi)) - return False - - def _node_costs(self, node): - # node score times a weight plus a threshold - score_costs = ((self.graph.nodes[node]['score'] * - self.parameters.weight_node_score) + - self.parameters.selection_constant) - - return score_costs - - def _edge_costs(self, edge): - # edge score times a weight - edge_costs = (self.graph.edges[edge]['prediction_distance'] * - self.parameters.weight_edge_score) - - return edge_costs - def _create_constraints(self): self.main_constraints = [] self._add_edge_constraints() + self._add_node_constraints() for t in range(self.graph.begin, self.graph.end): self._add_inter_frame_constraints(t) @@ -198,138 +145,93 @@ def _create_constraints(self): def _add_pin_constraints(self): - for e in self.graph.edges(): + logger.debug("setting pin constraints: %s", + self.pin_constraints_fn_list) - if self.selected_key in self.graph.edges[e]: - - selected = self.graph.edges[e][self.selected_key] - self.pinned_edges[e] = selected + for edge in self.graph.edges(): + if self.selected_key in self.graph.edges[edge]: + selected = self.graph.edges[edge][self.selected_key] + self.pinned_edges[edge] = selected - ind_e = self.edge_selected[e] - constraint = pylp.LinearConstraint() - constraint.set_coefficient(ind_e, 1) - constraint.set_relation(pylp.Relation.Equal) - constraint.set_value(1 if selected else 0) - self.pin_constraints.append(constraint) + for fn in self.pin_constraints_fn_list: + self.pin_constraints.extend( + fn(edge, self.indicators, selected)) def _add_edge_constraints(self): - logger.debug("setting edge constraints") + logger.debug("setting edge constraints: %s", + self.edge_constraints_fn_list) - for e in self.graph.edges(): + for edge in self.graph.edges(): + for fn in self.edge_constraints_fn_list: + self.main_constraints.extend(fn(edge, self.indicators)) - # if e is selected, u and v have to be selected - u, v = e - ind_e = self.edge_selected[e] - ind_u = self.node_selected[u] - ind_v = self.node_selected[v] + def _add_node_constraints(self): - constraint = pylp.LinearConstraint() - constraint.set_coefficient(ind_e, 2) - constraint.set_coefficient(ind_u, -1) - constraint.set_coefficient(ind_v, -1) - constraint.set_relation(pylp.Relation.LessEqual) - constraint.set_value(0) - self.main_constraints.append(constraint) + logger.debug("setting node constraints: %s", + self.node_constraints_fn_list) + + for node in self.graph.nodes(): + for fn in self.node_constraints_fn_list: + self.main_constraints.extend(fn(node, self.indicators)) - logger.debug("set edge constraint %s", constraint) def _add_inter_frame_constraints(self, t): '''Linking constraints from t to t+1.''' - logger.debug("setting inter-frame constraints for frame %d", t) + logger.debug("setting inter-frame constraints for frame %d: %s", t, + self.inter_frame_constraints_fn_list) - # Every selected node has exactly one selected edge to the previous and - # one or two to the next frame. This includes the special "appear" and - # "disappear" edges. - for node in self.graph.cells_by_frame(t): - # we model this as three constraints: - # sum(prev) - node = 0 # exactly one prev edge, - # iff node selected - # sum(next) - 2*node <= 0 # at most two next edges - # -sum(next) + node <= 0 # at least one next, iff node selected - - constraint_prev = pylp.LinearConstraint() - constraint_next_1 = pylp.LinearConstraint() - constraint_next_2 = pylp.LinearConstraint() - - # all neighbors in previous frame - pinned_to_1 = [] - for edge in self.graph.prev_edges(node): - constraint_prev.set_coefficient(self.edge_selected[edge], 1) - if edge in self.pinned_edges and self.pinned_edges[edge]: - pinned_to_1.append(edge) - if len(pinned_to_1) > 1: - raise RuntimeError( - "Node %d has more than one prev edge pinned: %s" - % (node, pinned_to_1)) - # plus "appear" - constraint_prev.set_coefficient(self.node_appear[node], 1) - - for edge in self.graph.next_edges(node): - constraint_next_1.set_coefficient(self.edge_selected[edge], 1) - constraint_next_2.set_coefficient(self.edge_selected[edge], -1) - # plus "disappear" - constraint_next_1.set_coefficient(self.node_disappear[node], 1) - constraint_next_2.set_coefficient(self.node_disappear[node], -1) - # node - - constraint_prev.set_coefficient(self.node_selected[node], -1) - constraint_next_1.set_coefficient(self.node_selected[node], -2) - constraint_next_2.set_coefficient(self.node_selected[node], 1) - # relation, value - - constraint_prev.set_relation(pylp.Relation.Equal) - constraint_next_1.set_relation(pylp.Relation.LessEqual) - constraint_next_2.set_relation(pylp.Relation.LessEqual) - - constraint_prev.set_value(0) - constraint_next_1.set_value(0) - constraint_next_2.set_value(0) - - self.main_constraints.append(constraint_prev) - self.main_constraints.append(constraint_next_1) - self.main_constraints.append(constraint_next_2) - - logger.debug( - "set inter-frame constraints:\t%s\n\t%s\n\t%s", - constraint_prev, constraint_next_1, constraint_next_2) - - # Ensure that the split indicator is set for every cell that splits - # into two daughter cells. for node in self.graph.cells_by_frame(t): + for fn in self.inter_frame_constraints_fn_list: + self.main_constraints.extend( + fn(node, self.indicators, self.graph, pinned_edges=self.pinned_edges)) - # I.e., each node with two forwards edges is a split node. - - # Constraint 1 - # sum(forward edges) - split <= 1 - # sum(forward edges) > 1 => split == 1 - - # Constraint 2 - # sum(forward edges) - 2*split >= 0 - # sum(forward edges) <= 1 => split == 0 - - constraint_1 = pylp.LinearConstraint() - constraint_2 = pylp.LinearConstraint() - - # sum(forward edges) - for edge in self.graph.next_edges(node): - constraint_1.set_coefficient(self.edge_selected[edge], 1) - constraint_2.set_coefficient(self.edge_selected[edge], 1) - # -[2*]split - constraint_1.set_coefficient(self.node_split[node], -1) - constraint_2.set_coefficient(self.node_split[node], -2) - - constraint_1.set_relation(pylp.Relation.LessEqual) - constraint_2.set_relation(pylp.Relation.GreaterEqual) - - constraint_1.set_value(1) - constraint_2.set_value(0) - - self.main_constraints.append(constraint_1) - self.main_constraints.append(constraint_2) - - logger.debug( - "set split-indicator constraints:\n\t%s\n\t%s", - constraint_1, constraint_2) +class BasicSolver(Solver): + '''Specialized class initialized with the basic indicators and constraints + ''' + def __init__(self, track_graph, timeout=120): + + pin_constraints_fn_list = [ensure_pinned_edge] + edge_constraints_fn_list = [ensure_edge_endpoints] + node_constraints_fn_list = [] + inter_frame_constraints_fn_list = [ + ensure_one_predecessor, + ensure_at_most_two_successors, + ensure_split_set_for_divs] + + node_indicator_keys = ["node_selected", "node_appear", "node_split"] + edge_indicator_keys = ["edge_selected"] + + super(BasicSolver, self).__init__( + track_graph, node_indicator_keys, edge_indicator_keys, + pin_constraints_fn_list, edge_constraints_fn_list, + node_constraints_fn_list, inter_frame_constraints_fn_list, + timeout=timeout) + + +class CellStateSolver(Solver): + '''Specialized class initialized with the indicators and constraints + necessary to include cell state information in addition to the basic + indicators and constraints + ''' + def __init__(self, track_graph, timeout=120): + + pin_constraints_fn_list = [ensure_pinned_edge] + edge_constraints_fn_list = [ensure_edge_endpoints, ensure_split_child] + node_constraints_fn_list = [ensure_one_state] + inter_frame_constraints_fn_list = [ + ensure_one_predecessor, + ensure_at_most_two_successors, + ensure_split_set_for_divs] + + node_indicator_keys = ["node_selected", "node_appear", "node_split", + "node_child", "node_continuation"] + edge_indicator_keys = ["edge_selected"] + + super(CellStateSolver, self).__init__( + track_graph, node_indicator_keys, edge_indicator_keys, + pin_constraints_fn_list, edge_constraints_fn_list, + node_constraints_fn_list, inter_frame_constraints_fn_list, + timeout=timeout) diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 7fd3560..cc5f509 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -2,16 +2,27 @@ parameter sets """ import logging +import os import time +import numpy as np +import pylp + from .solver import Solver from .track_graph import TrackGraph +from .cost_functions import (get_edge_indicator_fn_map_default, + get_node_indicator_fn_map_default) +from linajea.tracking import constraints + logger = logging.getLogger(__name__) -def track(graph, config, selected_key, frame_key='t', frames=None, - block_id=None): +def track(graph, config, selected_key, frame_key='t', + node_indicator_fn_map=None, edge_indicator_fn_map=None, + pin_constraints_fn_list=[], edge_constraints_fn_list=[], + node_constraints_fn_list=[], inter_frame_constraints_fn_list=[], + block_id=0): ''' A wrapper function that takes a daisy subgraph and input parameters, creates and solves the ILP to create tracks, and updates the daisy subgraph to reflect the selected nodes and edges. @@ -39,29 +50,76 @@ def track(graph, config, selected_key, frame_key='t', frames=None, The name of the node attribute that corresponds to the frame of the node. Defaults to "t". - frames (``list`` of ``int``): + node_indicator_fn_map (Callable): - The start and end frames to solve in (in case the graph doesn't - have nodes in all frames). Start is inclusive, end is exclusive. - Defaults to graph.begin, graph.end + Callable that returns a dict of str: Callable. + One entry per type of node indicator the solver should have; + The Callable stored in the value of each entry will be called + on each node and should return the cost for this indicator in + the objective. - block_id (``int``, optional): + edge_indicator_fn_map (Callable): + + Callable that returns a dict of str: Callable. + One entry per type of edge indicator the solver should have; + The Callable stored in the value of each entry will be called + on each edge and should return the cost for this indicator in + the objective. + + pin_constraints_fn_list (list of Callable) + + A list of Callable that return a list of pylp.LinearConstraint each. + Use this to add constraints to pin edge indicators to specific + states. Called only if edge has already been set by neighboring + blocks. + Interface: fn(edge, indicators, selected) + edge: Create constraints for this edge + indicators: The indicator map created by the Solver object + selected: Will be set to the selected state of this edge + + edge_constraints_fn_list (list of Callable) + + A list of Callable that return a list of pylp.LinearConstraint each. + Use this to add constraints on a specific edge. + Interface: fn(edge, indicators) + edge: Create constraints for this edge + indicators: The indicator map created by the Solver object + + node_constraints_fn_list (list of Callable) + + A list of Callable that return a list of pylp.LinearConstraint each. + Use this to add constraints on a specific node. + Interface: fn(node, indicators) + node: Create constraints for this node + indicators: The indicator map created by the Solver object + + inter_frame_constraints_fn_list (list of Callable) + + A list of Callable that return a list of pylp.LinearConstraint each. + d + Interface: fn(node, indicators, graph, **kwargs) + node: Create constraints for this node + indicators: The indicator map created by the Solver object + graph: The track graph that is solved. + **kwwargs: Additional parameters, so far only contains + `pinned_edges`, requires changes to Solver object to extend. - The ID of the current daisy block. + block_id (``int``, optional): + The ID of the current daisy block if data is processed block-wise. ''' # assuming graph is a daisy subgraph if graph.number_of_nodes() == 0: logger.info("No nodes in graph - skipping solving step") return - parameters = config.solve.parameters + parameters_sets = config.solve.parameters if not isinstance(selected_key, list): selected_key = [selected_key] - assert len(parameters) == len(selected_key),\ + assert len(parameters_sets) == len(selected_key),\ "%d parameter sets and %d selected keys" %\ - (len(parameters), len(selected_key)) + (len(parameters_sets), len(selected_key)) logger.debug("Creating track graph...") track_graph = TrackGraph(graph_data=graph, @@ -71,19 +129,48 @@ def track(graph, config, selected_key, frame_key='t', frames=None, logger.debug("Creating solver...") solver = None total_solve_time = 0 - for parameter, key in zip(parameters, selected_key): + + + if config.solve.solver_type is not None: + constrs = constraints.get_constraints_default(config) + pin_constraints_fn_list = constrs[0] + edge_constraints_fn_list = constrs[1] + node_constraints_fn_list = constrs[2] + inter_frame_constraints_fn_list = constrs[3] + + for parameters, key in zip(parameters_sets, selected_key): + if node_indicator_fn_map is None: + _node_indicator_fn_map = get_node_indicator_fn_map_default( + config, parameters, track_graph) + else: + _node_indicator_fn_map = node_indicator_fn_map(config, parameters, + track_graph) + if edge_indicator_fn_map is None: + _edge_indicator_fn_map = get_edge_indicator_fn_map_default( + config, parameters) + else: + _edge_indicator_fn_map = edge_indicator_fn_map(config, parameters, + track_graph) + if not solver: solver = Solver( - track_graph, parameter, key, frames=frames, - block_id=block_id, - check_node_close_to_roi=config.solve.check_node_close_to_roi, + track_graph, + list(_node_indicator_fn_map.keys()), + list(_edge_indicator_fn_map.keys()), + pin_constraints_fn_list, edge_constraints_fn_list, + node_constraints_fn_list, inter_frame_constraints_fn_list, timeout=config.solve.timeout) - else: - solver.update_objective(parameter, key) + solver.update_objective(_node_indicator_fn_map, + _edge_indicator_fn_map, key) + + if config.solve.write_struct_svm: + write_struct_svm(solver, block_id, config.solve.write_struct_svm) + logger.info("wrote struct svm data, skipping solving") + break logger.info("Solving for key %s", str(key)) start_time = time.time() - solver.solve() + solver.solve_and_set() end_time = time.time() total_solve_time += end_time - start_time logger.info("Solving ILP took %s seconds", str(end_time - start_time)) @@ -93,3 +180,64 @@ def track(graph, config, selected_key, frame_key='t', frames=None, data[key] = track_graph.edges[(u, v)][key] logger.info("Solving ILP for all parameters took %s seconds", str(total_solve_time)) + + +def write_struct_svm(solver, block_id, output_dir): + os.makedirs(output_dir, exist_ok=True) + + # write all indicators as "object_id, indicator_id" + for k, objs in solver.indicators.items(): + with open(os.path.join(output_dir, k + "_b" + str(block_id)), 'w') as f: + for obj, v in objs.items(): + f.write(f"{obj} {v}\n") + + # write features for objective + indicators = {} + num_features = {} + for k, fn in solver.node_indicator_fn_map.items(): + for n_id, node in solver.graph.nodes(data=True): + ind = solver.indicators[k][n_id] + costs = fn(node) + indicators[ind] = (k, costs) + num_features[k] = len(costs) + for k, fn in solver.edge_indicator_fn_map.items(): + for u, v, edge in solver.graph.edges(data=True): + ind = solver.indicators[k][(u, v)] + costs = fn(edge) + indicators[ind] = (k, costs) + num_features[k] = len(costs) + + features_locs = {} + acc = 0 + for k, v in num_features.items(): + features_locs[k] = acc + acc += v + num_features = acc + assert sorted(indicators.keys()) == list(range(len(indicators))), \ + "some error reading indicators and features" + with open(os.path.join(output_dir, "features_b" + str(block_id)), 'w') as f: + for ind in sorted(indicators.keys()): + k, costs = indicators[ind] + features = [0]*num_features + features_loc = features_locs[k] + features[features_loc:features_loc+len(costs)] = costs + f.write(" ".join([str(f) for f in features]) + "\n") + + # write constraints + def rel_to_str(rel): + if rel == pylp.Relation.Equal: + return " == " + elif rel == pylp.Relation.LessEqual: + return " <= " + elif rel == pylp.Relation.GreaterEqual: + return " >= " + else: + raise RuntimeError("invalid pylp.Relation: %s", rel) + + with open(os.path.join(output_dir, "constraints_b" + str(block_id)), 'w') as f: + for constraint in solver.main_constraints: + val = constraint.get_value() + rel = rel_to_str(constraint.get_relation()) + coeffs = " ".join([f"{v}*{idx}" + for idx, v in constraint.get_coefficients().items()]) + f.write(f"{coeffs} {rel} {val}\n") diff --git a/linajea/utils/candidate_database.py b/linajea/utils/candidate_database.py index 334b189..0a57fe7 100644 --- a/linajea/utils/candidate_database.py +++ b/linajea/utils/candidate_database.py @@ -214,10 +214,10 @@ def get_parameters_id_round( (entry['roi']['offset'] != query['roi']['offset'] or \ entry['roi']['shape'] != query['roi']['shape']): break - elif param == 'cell_cycle_key': - if '$exists' in query['cell_cycle_key'] and \ - query['cell_cycle_key']['$exists'] == False and \ - 'cell_cycle_key' in entry: + elif param == 'cell_state_key': + if '$exists' in query['cell_state_key'] and \ + query['cell_state_key']['$exists'] == False and \ + 'cell_state_key' in entry: break elif isinstance(query[param], str): if param not in entry or \ diff --git a/linajea/utils/get_next_inference_data.py b/linajea/utils/get_next_inference_data.py index 1a3d1cc..c867247 100644 --- a/linajea/utils/get_next_inference_data.py +++ b/linajea/utils/get_next_inference_data.py @@ -108,10 +108,12 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): if hasattr(args, "val_param_id") and (is_solve or is_evaluate) and \ args.val_param_id is not None: config = _fix_val_param_pid(args, config, checkpoint) + solve_parameters_sets = deepcopy(config.solve.parameters) if hasattr(args, "param_id") and (is_solve or is_evaluate) and \ (args.param_id is not None or (hasattr(args, "param_ids") and args.param_ids is not None)): config = _fix_param_pid(args, config, checkpoint, inference_data) + solve_parameters_sets = deepcopy(config.solve.parameters) inference_data_tmp = { 'checkpoint': checkpoint, 'cell_score_threshold': inference_data.cell_score_threshold} @@ -182,7 +184,8 @@ def _fix_param_pid(args, config, checkpoint, inference_data): db_meta_info = { "sample": inference_data.data_sources[0].datafile.filename, "iteration": checkpoint, - "cell_score_threshold": inference_data.cell_score_threshold + "cell_score_threshold": inference_data.cell_score_threshold, + "roi": inference_data.data_sources[0].roi } db_name = None else: From 176c3fdd39d7bb35ab10cfc85ba97bbcbe7a8a1f Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Mon, 18 Jul 2022 09:28:24 -0400 Subject: [PATCH 209/263] fix flake8 errors --- linajea/config/augment.py | 11 ++- linajea/config/config_test.py | 33 +++---- linajea/config/data.py | 11 +-- linajea/config/evaluate.py | 3 +- linajea/config/optimizer.py | 4 +- linajea/config/predict.py | 7 +- linajea/config/solve.py | 45 +++++----- linajea/config/tracking_config.py | 21 +++-- linajea/config/train.py | 7 +- linajea/config/train_test_validate_data.py | 19 ++-- linajea/config/unet_config.py | 23 +++-- linajea/config/utils.py | 20 +++-- linajea/evaluation/analyze_results.py | 1 - linajea/evaluation/division_evaluation.py | 2 +- linajea/evaluation/evaluate.py | 4 +- linajea/evaluation/evaluate_setup.py | 5 +- linajea/evaluation/evaluator.py | 41 +++++---- linajea/evaluation/match.py | 5 +- linajea/evaluation/report.py | 1 - .../gunpowder_nodes/add_movement_vectors.py | 11 ++- linajea/gunpowder_nodes/cast.py | 3 + linajea/gunpowder_nodes/normalize.py | 3 +- linajea/gunpowder_nodes/shift_augment.py | 12 +-- linajea/gunpowder_nodes/tracks_source.py | 5 +- linajea/gunpowder_nodes/write_cells.py | 6 +- linajea/prediction/predict.py | 15 ++-- linajea/prediction/write_cells_from_zarr.py | 28 +++--- .../daisy_check_functions.py | 1 + .../process_blockwise/predict_blockwise.py | 1 - linajea/process_blockwise/solve_blockwise.py | 6 +- linajea/run_scripts/01_train.py | 3 +- linajea/run_scripts/04_solve.py | 6 +- linajea/run_scripts/05_evaluate.py | 3 +- linajea/run_scripts/06_run_best_config.py | 10 ++- linajea/run_scripts/run.py | 42 +++++---- linajea/tracking/constraints.py | 2 +- linajea/tracking/cost_functions.py | 7 +- linajea/tracking/solver.py | 21 +++-- linajea/tracking/track.py | 22 ++--- linajea/training/torch_loss.py | 20 ++--- linajea/training/torch_model.py | 43 +++++---- linajea/training/train.py | 90 +++++++++---------- linajea/training/utils.py | 16 +++- linajea/utils/candidate_database.py | 45 +++------- linajea/utils/check_or_create_db.py | 3 +- linajea/utils/construct_zarr_filename.py | 6 +- linajea/utils/get_next_inference_data.py | 35 ++++---- linajea/utils/parse_tracks_file.py | 11 +-- linajea/utils/write_search_files.py | 10 +-- tests/test_greedy_baseline.py | 2 +- 50 files changed, 398 insertions(+), 353 deletions(-) diff --git a/linajea/config/augment.py b/linajea/config/augment.py index 8ff5495..9175ed3 100644 --- a/linajea/config/augment.py +++ b/linajea/config/augment.py @@ -120,6 +120,7 @@ class AugmentNoiseGaussianConfig: """ var = attr.ib(type=float, default=0.01) + @attr.s(kw_only=True) class AugmentNoiseSpeckleConfig: """Defines options for Speckle noise augment, uses scikit-image @@ -131,6 +132,7 @@ class AugmentNoiseSpeckleConfig: """ var = attr.ib(type=float, default=0.05) + @attr.s(kw_only=True) class AugmentNoiseSaltPepperConfig: """Defines options for S&P noise augment, uses scikit-image @@ -142,6 +144,7 @@ class AugmentNoiseSaltPepperConfig: """ amount = attr.ib(type=float, default=0.0001) + @attr.s(kw_only=True) class AugmentZoomConfig: """Defines options for Zoom augment @@ -159,6 +162,7 @@ class AugmentZoomConfig: factor_max = attr.ib(type=float, default=1.25) spatial_dims = attr.ib(type=int, default=3) + @attr.s(kw_only=True) class AugmentHistogramConfig: """Defines options for Zoom augment @@ -236,8 +240,9 @@ class _AugmentConfig: noise_gaussian = attr.ib(converter=ensure_cls(AugmentNoiseGaussianConfig), default=None) noise_speckle = attr.ib(converter=ensure_cls(AugmentNoiseSpeckleConfig), - default=None) - noise_saltpepper = attr.ib(converter=ensure_cls(AugmentNoiseSaltPepperConfig), + default=None) + noise_saltpepper = attr.ib(converter=ensure_cls( + AugmentNoiseSaltPepperConfig), default=None) zoom = attr.ib(converter=ensure_cls(AugmentZoomConfig), default=None) @@ -254,7 +259,7 @@ class AugmentTrackingConfig(_AugmentConfig): reject_empty_prob: float Probability that completely empty patches are discarded divisions: float - Choose (x*100)% of patches such that they include a division (e.g. 0.25) + Choose (x*100)% of patches such that they include a division (eg 0.25) point_balance_radius: int Defines radius per point, the more other points within radius the lower the probability that point is picked, helps to avoid diff --git a/linajea/config/config_test.py b/linajea/config/config_test.py index c251e36..1bf832f 100644 --- a/linajea/config/config_test.py +++ b/linajea/config/config_test.py @@ -1,11 +1,7 @@ -import attr - from linajea import load_config from linajea.config import ( TrackingConfig, - CellCycleConfig, GeneralConfig, - DataFileConfig, JobConfig, PredictTrackingConfig, ExtractConfig, @@ -16,33 +12,28 @@ if __name__ == "__main__": # parts of config config_dict = load_config("sample_config_tracking.toml") - general_config = GeneralConfig(**config_dict['general']) # type: ignore + general_config = GeneralConfig(**config_dict['general']) # type: ignore print(general_config) - # data_config = DataFileConfig(**config_dict['train']['data']) # type: ignore - # print(data_config) - job_config = JobConfig(**config_dict['train']['job']) # type: ignore + job_config = JobConfig(**config_dict['train']['job']) # type: ignore print(job_config) # tracking parts of config - predict_config = PredictTrackingConfig(**config_dict['predict']) # type: ignore + predict_config = PredictTrackingConfig( + **config_dict['predict']) # type: ignore print(predict_config) - extract_config = ExtractConfig(**config_dict['extract']) # type: ignore + extract_config = ExtractConfig(**config_dict['extract']) # type: ignore print(extract_config) - solve_config = SolveConfig(**config_dict['solve']) # type: ignore + solve_config = SolveConfig(**config_dict['solve']) # type: ignore print(solve_config) - evaluate_config = EvaluateTrackingConfig(**config_dict['evaluate']) # type: ignore + evaluate_config = EvaluateTrackingConfig( + **config_dict['evaluate']) # type: ignore print(evaluate_config) - # complete configs - tracking_config = TrackingConfig(path="sample_config_tracking.toml", **config_dict) # type: ignore + tracking_config = TrackingConfig( + path="sample_config_tracking.toml", **config_dict) # type: ignore print(tracking_config) - tracking_config = TrackingConfig.from_file("sample_config_tracking.toml") + tracking_config = TrackingConfig.from_file( + "sample_config_tracking.toml") print(tracking_config) - - # config_dict = load_config("sample_config_cellcycle.toml") - cell_cycle_config = CellCycleConfig.from_file("sample_config_cellcycle.toml") # type: ignore - print(cell_cycle_config) - - # print(attr.asdict(cell_cycle_config)) diff --git a/linajea/config/data.py b/linajea/config/data.py index 8f398d7..0812d26 100644 --- a/linajea/config/data.py +++ b/linajea/config/data.py @@ -11,7 +11,6 @@ import attr import daisy -import zarr from .utils import (ensure_cls, load_config, @@ -82,8 +81,9 @@ def __attrs_post_init__(self): dataset = daisy.open_ds(self.filename, self.group) self.file_voxel_size = dataset.voxel_size - self.file_roi = DataROIConfig(offset=dataset.roi.get_offset(), - shape=dataset.roi.get_shape()) # type: ignore + self.file_roi = DataROIConfig( + offset=dataset.roi.get_offset(), + shape=dataset.roi.get_shape()) # type: ignore else: filename = self.filename is_polar = "polar" in filename @@ -99,8 +99,9 @@ def __attrs_post_init__(self): self.file_voxel_size = data_config['general']['resolution'] self.file_roi = DataROIConfig( offset=data_config['general']['offset'], - shape=[s*v for s,v in zip(data_config['general']['shape'], - self.file_voxel_size)]) # type: ignore + shape=[s*v for s, v in zip( + data_config['general']['shape'], + self.file_voxel_size)]) # type: ignore if self.group is None: self.group = data_config['general']['group'] diff --git a/linajea/config/evaluate.py b/linajea/config/evaluate.py index 274cc9b..1c91fea 100644 --- a/linajea/config/evaluate.py +++ b/linajea/config/evaluate.py @@ -89,4 +89,5 @@ class EvaluateTrackingConfig(_EvaluateConfig): Which evaluation parameters to use """ from_scratch = attr.ib(type=bool, default=False) - parameters = attr.ib(converter=ensure_cls(EvaluateParametersTrackingConfig)) + parameters = attr.ib(converter=ensure_cls( + EvaluateParametersTrackingConfig)) diff --git a/linajea/config/optimizer.py b/linajea/config/optimizer.py index 19dfc57..269e01e 100644 --- a/linajea/config/optimizer.py +++ b/linajea/config/optimizer.py @@ -43,6 +43,7 @@ class OptimizerTorchKwargsConfig: nesterov = attr.ib(type=bool, default=None) weight_decay = attr.ib(type=float, default=None) + @attr.s(kw_only=True) class OptimizerTorchConfig: """Defines which pytorch optimizer to use @@ -65,4 +66,5 @@ class OptimizerTorchConfig: def get_kwargs(self): """Get dict of keyword parameters""" - return {a:v for a,v in attr.asdict(self.kwargs).items() if v is not None} + return {a: v for a, v in attr.asdict(self.kwargs).items() + if v is not None} diff --git a/linajea/config/predict.py b/linajea/config/predict.py index 7a40f28..61a0dfa 100644 --- a/linajea/config/predict.py +++ b/linajea/config/predict.py @@ -29,6 +29,7 @@ class _PredictConfig: normalization = attr.ib(converter=ensure_cls(NormalizeConfig), default=None) + @attr.s(kw_only=True) class PredictTrackingConfig(_PredictConfig): """Defines specialized class for configuration of tracking prediction @@ -61,6 +62,6 @@ def __attrs_post_init__(self): assert self.write_to_zarr or self.write_to_db, \ "prediction not written, set write_to_zarr or write_to_db to true!" assert not ((self.write_to_db or self.write_db_from_zarr) and - self.no_db_access), \ - ("no_db_access can only be set if no data is written " - "to db (it then disables db done checks for write to zarr)") + self.no_db_access), ( + "no_db_access can only be set if no data is written " + "to db (it then disables db done checks for write to zarr)") diff --git a/linajea/config/solve.py b/linajea/config/solve.py index eeac36d..5b33ebc 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -11,8 +11,7 @@ from .data import DataROIConfig from .job import JobConfig from .utils import (ensure_cls, - ensure_cls_list, - load_config) + ensure_cls_list) logger = logging.getLogger(__name__) @@ -51,8 +50,6 @@ class SolveParametersConfig: as part of cross-validation) tag: str To automatically tag e.g. ssvm/greedy solutions - - """ track_cost = attr.ib(type=float) weight_node_score = attr.ib(type=float) @@ -171,8 +168,8 @@ def write_solve_parameters_configs(parameters_search, grid): itertools.product. If num_configs is set, shuffle list and take the num_configs first ones. """ - params = {k:v - for k,v in attr.asdict(parameters_search).items() + params = {k: v + for k, v in attr.asdict(parameters_search).items() if v is not None} params.pop('num_configs', None) @@ -190,8 +187,9 @@ def write_solve_parameters_configs(parameters_search, grid): value = v[0] elif isinstance(v[0], str) or len(v) > 2: value = random.choice(v) - elif len(v) == 2 and isinstance(v[0], list) and isinstance(v[1], list) and \ - isinstance(v[0][0], str) and isinstance(v[1][0], str): + elif (len(v) == 2 and isinstance(v[0], list) and + isinstance(v[1], list) and + isinstance(v[0][0], str) and isinstance(v[1][0], str)): subset = random.choice(v) value = random.choice(subset) else: @@ -211,8 +209,8 @@ def write_solve_parameters_configs(parameters_search, grid): else: if params.get('cell_state_key') == '': params['cell_state_key'] = None - elif isinstance(params.get('cell_state_key'), list) and \ - '' in params['cell_state_key']: + elif (isinstance(params.get('cell_state_key'), list) and + '' in params['cell_state_key']): params['cell_state_key'] = [k if k != '' else None for k in params['cell_state_key']] search_configs = [ @@ -245,7 +243,8 @@ class SolveConfig: If solution should be recomputed if it already exists parameters: SolveParametersConfig Fixed set of ILP parameters - parameters_search_grid, parameters_search_random: SolveParametersSearchConfig + parameters_search_grid + parameters_search_random: SolveParametersSearchConfig Ranges/sets per ILP parameter to create parameter search greedy: bool Do not use ILP for solving, greedy nearest neighbor tracking @@ -288,9 +287,10 @@ class SolveConfig: grid_search = attr.ib(type=bool, default=False) random_search = attr.ib(type=bool, default=False) solver_type = attr.ib(type=str, default=None, - validator=attr.validators.optional(attr.validators.in_([ - "basic", - "cell_state"]))) + validator=attr.validators.optional( + attr.validators.in_([ + "basic", + "cell_state"]))) def __attrs_post_init__(self): assert self.parameters is not None or \ @@ -303,13 +303,14 @@ def __attrs_post_init__(self): assert self.grid_search != self.random_search, \ "choose either grid or random search!" assert self.parameters is None, \ - ("overwriting explicit solve parameters with grid/random search " - "parameters not supported. For search please either (1) " - "precompute search parameters (e.g. using write_config_files.py) " - "and set solve.parameters to point to resulting file or (2) " - "let search parameters be created automatically by setting " - "solve.grid/random_search to true (only supported when using " - "the getNextInferenceData facility to loop over data samples)") + ("overwriting explicit solve parameters with grid/random " + "search parameters not supported. For search please either " + "(1) precompute search parameters (e.g. using " + "write_config_files.py) and set solve.parameters to point to " + "resulting file or (2) let search parameters be created " + "automatically by setting solve.grid/random_search to true " + "(only supported when using the getNextInferenceData " + "facility to loop over data samples)") if self.parameters is not None: logger.warning("overwriting explicit solve parameters with " "grid/random search parameters!") @@ -318,7 +319,7 @@ def __attrs_post_init__(self): "provide grid search values for solve parameters " \ "if grid search activated" parameters_search = self.parameters_search_grid - else: #if self.random_search: + else: assert self.parameters_search_random is not None, \ "provide random search values for solve parameters " \ "if random search activated" diff --git a/linajea/config/tracking_config.py b/linajea/config/tracking_config.py index 5ff920c..f44e502 100644 --- a/linajea/config/tracking_config.py +++ b/linajea/config/tracking_config.py @@ -24,6 +24,7 @@ from .utils import (ensure_cls, load_config) + @attr.s(kw_only=True) class TrackingConfig: """Defines the configuration for a tracking experiment @@ -70,7 +71,8 @@ class TrackingConfig: default=None) inference_data = attr.ib(converter=ensure_cls(InferenceDataTrackingConfig), default=None) - predict = attr.ib(converter=ensure_cls(PredictTrackingConfig), default=None) + predict = attr.ib(converter=ensure_cls(PredictTrackingConfig), + default=None) extract = attr.ib(converter=ensure_cls(ExtractConfig), default=None) solve = attr.ib(converter=ensure_cls(SolveConfig), default=None) evaluate = attr.ib(converter=ensure_cls(EvaluateTrackingConfig), @@ -84,7 +86,7 @@ def from_file(cls, path): """ config_dict = load_config(path) config_dict["path"] = path - return cls(**config_dict) # type: ignore + return cls(**config_dict) # type: ignore def __attrs_post_init__(self): """Validate supplied parameters @@ -100,10 +102,14 @@ def __attrs_post_init__(self): self.predict.use_swa = self.train.use_swa dss = [] - dss += self.train_data.data_sources if self.train_data is not None else [] - dss += self.test_data.data_sources if self.test_data is not None else [] - dss += self.validate_data.data_sources if self.validate_data is not None else [] - dss += [self.inference_data.data_source] if self.inference_data is not None else [] + dss += self.train_data.data_sources \ + if self.train_data is not None else [] + dss += self.test_data.data_sources \ + if self.test_data is not None else [] + dss += self.validate_data.data_sources \ + if self.validate_data is not None else [] + dss += [self.inference_data.data_source] \ + if self.inference_data is not None else [] for sample in dss: if sample.roi is None: try: @@ -114,7 +120,8 @@ def __attrs_post_init__(self): except Exception as e: raise RuntimeError( "please specify roi for data! not set and unable to " - "determine it automatically based on given db (db_name) (%s)" % e) + "determine it automatically based on given db " + "(db_name) (%s)" % e) if self.predict is not None: if self.predict.normalization is None and \ diff --git a/linajea/config/train.py b/linajea/config/train.py index 64fd552..3be50be 100644 --- a/linajea/config/train.py +++ b/linajea/config/train.py @@ -6,7 +6,6 @@ from .augment import (AugmentTrackingConfig, NormalizeConfig) -from .train_test_validate_data import TrainDataTrackingConfig from .job import JobConfig from .utils import ensure_cls @@ -73,11 +72,13 @@ class _TrainConfig: swa_freq_it = attr.ib(type=int, default=None) use_grad_norm = attr.ib(type=bool, default=False) val_log_step = attr.ib(type=int, default=None) - normalization = attr.ib(converter=ensure_cls(NormalizeConfig), default=None) + normalization = attr.ib(converter=ensure_cls(NormalizeConfig), + default=None) def __attrs_post_init__(self): if self.use_swa: - assert self.swa_start_it is not None and self.swa_freq_it is not None, \ + assert (self.swa_start_it is not None and + self.swa_freq_it is not None), \ "if swa is used, please set start and freq it" diff --git a/linajea/config/train_test_validate_data.py b/linajea/config/train_test_validate_data.py index 2652c8a..ec5ec3d 100644 --- a/linajea/config/train_test_validate_data.py +++ b/linajea/config/train_test_validate_data.py @@ -6,7 +6,6 @@ otherwise define InferenceData (only a single sample, data source) """ from copy import deepcopy -import os from typing import List import attr @@ -37,6 +36,7 @@ class _DataConfig(): voxel_size = attr.ib(type=List[int], default=None) roi = attr.ib(converter=ensure_cls(DataROIConfig), default=None) group = attr.ib(type=str, default=None) + def __attrs_post_init__(self): """Validate the supplied parameters and try to fix missing ones @@ -82,7 +82,7 @@ def __attrs_post_init__(self): assert all(ds.voxel_size == self.data_sources[0].voxel_size for ds in self.data_sources), \ - "data sources with varying voxel_size not supported" + "data sources with varying voxel_size not supported" @attr.s(kw_only=True) @@ -90,9 +90,10 @@ class TrainDataTrackingConfig(_DataConfig): """Defines a specialized class for the definition of a training data set """ data_sources = attr.ib(converter=ensure_cls_list(DataSourceConfig)) + @data_sources.validator def _check_train_data_source(self, attribute, value): - """a train data source has to use datafiles and cannot have a database""" + """train data source has to use datafiles and cannot have a database""" for ds in value: if ds.db_name is not None: raise ValueError("train data_sources must not have a db_name") @@ -146,11 +147,13 @@ def __attrs_post_init__(self): database does not contain the respective information an error will be thrown later in the pipeline. """ - if self.data_source.datafile is not None: - if self.data_source.voxel_size is None: - self.data_source.voxel_size = self.data_source.datafile.file_voxel_size - if self.data_source.roi is None: - self.data_source.roi = self.data_source.datafile.file_roi + d = self.data_source + if d.datafile is not None: + if d.voxel_size is None: + d.voxel_size = d.datafile.file_voxel_size + if d.roi is None: + d.roi = d.datafile.file_roi + @attr.s(kw_only=True) class ValidateDataTrackingConfig(_DataConfig): diff --git a/linajea/config/unet_config.py b/linajea/config/unet_config.py index 3e6ad77..a5bf7fa 100644 --- a/linajea/config/unet_config.py +++ b/linajea/config/unet_config.py @@ -5,9 +5,7 @@ import attr -from .job import JobConfig -from .utils import (ensure_cls, - _check_nd_shape, +from .utils import (_check_nd_shape, _int_list_validator, _list_int_list_validator, _check_possible_nested_lists) @@ -87,15 +85,16 @@ class UnetConfig: default=None, validator=_check_possible_nested_lists) upsampling = attr.ib(type=str, default=None, - validator=attr.validators.optional(attr.validators.in_([ - "transposed_conv", - "sep_transposed_conv", # depthwise + pixelwise - "resize_conv", - "uniform_transposed_conv", - "pixel_shuffle", - "trilinear", # aka 3d bilinear - "nearest" - ]))) + validator=attr.validators.optional( + attr.validators.in_([ + "transposed_conv", + "sep_transposed_conv", # depthwise+pixelwise + "resize_conv", + "uniform_transposed_conv", + "pixel_shuffle", + "trilinear", # aka 3d bilinear + "nearest" + ]))) constant_upsample = attr.ib(type=bool, default=None) nms_window_shape = attr.ib(type=List[int], validator=[_int_list_validator, diff --git a/linajea/config/utils.py b/linajea/config/utils.py index 0d405ff..b6eccf2 100644 --- a/linajea/config/utils.py +++ b/linajea/config/utils.py @@ -84,9 +84,9 @@ def converter(val): def ensure_cls_list(cl): """attrs converter to ensure type of values in list - If the attribute is an list of instances of cls, pass, else try constructing. - This way a list of instances of an attrs config object can be passed - or a list of dicts that can be used to construct such instances. + If the attribute is an list of instances of cls, pass, else try + constructing. This way a list of instances of an attrs config object can be + passed or a list of dicts that can be used to construct such instances. Raises ------ @@ -126,6 +126,7 @@ def _check_shape(self, attribute, value): raise ValueError("{} must be {}d".format(attribute, ndims)) return _check_shape + def _check_nested_nd_shape(ndims): """attrs validator to verify length of list @@ -138,6 +139,7 @@ def _check_shape(self, attribute, value): raise ValueError("{} must be {}d".format(attribute, ndims)) return _check_shape + """ _int_list_validator: attrs validator to validate list of ints @@ -152,17 +154,20 @@ def _check_shape(self, attribute, value): member_validator=_int_list_validator, iterable_validator=attr.validators.instance_of(list)) + def _check_possible_nested_lists(self, attribute, value): """attrs validator to verify list of ints or list of lists of ints """ try: attr.validators.deep_iterable( member_validator=_int_list_validator, - iterable_validator=attr.validators.instance_of(list))(self,attribute, value) - except: + iterable_validator=attr.validators.instance_of(list))( + self, attribute, value) + except TypeError: attr.validators.deep_iterable( member_validator=_list_int_list_validator, - iterable_validator=attr.validators.instance_of(list))(self, attribute, value) + iterable_validator=attr.validators.instance_of(list))( + self, attribute, value) def maybe_fix_config_paths_to_machine_and_load(config): @@ -184,7 +189,8 @@ def maybe_fix_config_paths_to_machine_and_load(config): config_dict["path"] = config if os.path.isfile(os.path.join(os.environ['HOME'], "linajea_paths.toml")): - paths = load_config(os.path.join(os.environ['HOME'], "linajea_paths.toml")) + paths = load_config(os.path.join(os.environ['HOME'], + "linajea_paths.toml")) if "general" in config_dict: config_dict["general"]["setup_dir"] = \ diff --git a/linajea/evaluation/analyze_results.py b/linajea/evaluation/analyze_results.py index 3e97789..119594c 100644 --- a/linajea/evaluation/analyze_results.py +++ b/linajea/evaluation/analyze_results.py @@ -140,7 +140,6 @@ def get_results_sorted_db(db_name, scores = candidate_db.get_scores(filters=filter_params, eval_params=eval_params) - if len(scores) == 0: raise RuntimeError("no scores found!") diff --git a/linajea/evaluation/division_evaluation.py b/linajea/evaluation/division_evaluation.py index 33e814c..64dcc3d 100644 --- a/linajea/evaluation/division_evaluation.py +++ b/linajea/evaluation/division_evaluation.py @@ -119,7 +119,7 @@ def evaluate_divisions( matches = [] else: matches, soln_cost = match(costs, - matching_threshold + 1) + matching_threshold + 1) logger.info("found %d matches in target frame" % len(matches)) report = calculate_report(gt_node_ids, rec_node_ids, matches) reports.append(report) diff --git a/linajea/evaluation/evaluate.py b/linajea/evaluation/evaluate.py index 0b87e97..b021ba6 100644 --- a/linajea/evaluation/evaluate.py +++ b/linajea/evaluation/evaluate.py @@ -67,8 +67,8 @@ def evaluate( logger.info("Done matching. Evaluating") edge_matches = [(gt_edges[gt_ind], rec_edges[rec_ind]) for gt_ind, rec_ind in edge_matches] - unselected_potential_matches = [rec_edges[rec_ind] - for rec_ind in unselected_potential_matches] + unselected_potential_matches = [ + rec_edges[rec_ind] for rec_ind in unselected_potential_matches] evaluator = Evaluator( gt_track_graph, rec_track_graph, diff --git a/linajea/evaluation/evaluate_setup.py b/linajea/evaluation/evaluate_setup.py index 403dc8d..d9f25e3 100644 --- a/linajea/evaluation/evaluate_setup.py +++ b/linajea/evaluation/evaluate_setup.py @@ -49,7 +49,8 @@ def evaluate_setup(linajea_config): logger.debug("ROI used for evaluation: %s %s", linajea_config.evaluate.parameters.roi, data.roi) if linajea_config.evaluate.parameters.roi is not None: - assert linajea_config.evaluate.parameters.roi.shape[0] <= data.roi.shape[0], \ + assert (linajea_config.evaluate.parameters.roi.shape[0] <= + data.roi.shape[0]), \ "your evaluation ROI is larger than your data roi!" data.roi = linajea_config.evaluate.parameters.roi else: @@ -126,7 +127,7 @@ def evaluate_setup(linajea_config): linajea_config.evaluate.parameters.ignore_one_off_div_errors fn_div_count_unconnected_parent = \ linajea_config.evaluate.parameters.fn_div_count_unconnected_parent - window_size=linajea_config.evaluate.parameters.window_size + window_size = linajea_config.evaluate.parameters.window_size report = evaluate( gt_track_graph, diff --git a/linajea/evaluation/evaluator.py b/linajea/evaluation/evaluator.py index b0ae8ab..055e0b1 100644 --- a/linajea/evaluation/evaluator.py +++ b/linajea/evaluation/evaluator.py @@ -429,14 +429,16 @@ def get_perfect_segments(self, window_size): while len(next_edges) > 0: if correct: # check current node and next edge - for current_node in current_nodes: - if current_node != start_node: - if ('IS' in self.gt_track_graph.nodes[current_node] or - 'FP_D' in self.gt_track_graph.nodes[current_node] or - 'FN_D' in self.gt_track_graph.nodes[current_node]): + for cn in current_nodes: + if cn != start_node: + ns = self.gt_track_graph.nodes[cn] + if 'IS' in ns or \ + 'FP_D' in ns or \ + 'FN_D' in ns: correct = False for next_edge in next_edges: - if 'FN' in self.gt_track_graph.get_edge_data(*next_edge): + if 'FN' in self.gt_track_graph.get_edge_data( + *next_edge): correct = False # update segment counts total_segments[frames] += 1 @@ -549,21 +551,18 @@ def get_div_topology_stats(self): def _get_local_graphs(self, div_node, g1, g2, rec_to_gt=False): g1_nodes = [] - try: - for n1 in g1.successors(div_node): - g1_nodes.append(n1) - for n2 in g1.successors(n1): - g1_nodes.append(n2) - for n2 in g1.predecessors(n1): - g1_nodes.append(n2) - for n1 in g1.predecessors(div_node): - g1_nodes.append(n1) - for n2 in g1.successors(n1): - g1_nodes.append(n2) - for n2 in g1.predecessors(n1): - g1_nodes.append(n2) - except: - raise RuntimeError("Overlooked edge case in _get_local_graph?") + for n1 in g1.successors(div_node): + g1_nodes.append(n1) + for n2 in g1.successors(n1): + g1_nodes.append(n2) + for n2 in g1.predecessors(n1): + g1_nodes.append(n2) + for n1 in g1.predecessors(div_node): + g1_nodes.append(n1) + for n2 in g1.successors(n1): + g1_nodes.append(n2) + for n2 in g1.predecessors(n1): + g1_nodes.append(n2) prev_edge = list(g1.prev_edges(div_node)) prev_edge = prev_edge[0] if len(prev_edge) > 0 else None diff --git a/linajea/evaluation/match.py b/linajea/evaluation/match.py index 2bc2ac5..95d5c1b 100644 --- a/linajea/evaluation/match.py +++ b/linajea/evaluation/match.py @@ -118,7 +118,8 @@ def match_edges(track_graph_x, track_graph_y, matching_threshold): edge_matches_in_frame, _ = match(edge_costs, 2*matching_threshold + 1) edge_matches.extend(edge_matches_in_frame) - y_edge_matches_in_frame = [edge[1] for edge in edge_matches_in_frame] + y_edge_matches_in_frame = [edge[1] + for edge in edge_matches_in_frame] edge_fps_in_frame = set(y_edges_in_range) -\ set(y_edge_matches_in_frame) edge_fps += list(edge_fps_in_frame) @@ -327,7 +328,7 @@ def match(costs, no_match_cost): solver.set_timeout(240) logger.debug("start solving (num vars %d, num constr. %d, num costs %d)", - num_variables, len(constraints), len(costs)) + num_variables, len(constraints), len(costs)) start = time.time() solution, message = solver.solve() end = time.time() diff --git a/linajea/evaluation/report.py b/linajea/evaluation/report.py index 335f01e..01bcf76 100644 --- a/linajea/evaluation/report.py +++ b/linajea/evaluation/report.py @@ -294,7 +294,6 @@ def set_iso_fp_divisions(self, iso_fp_div_nodes): logger.debug("fp edges after iso_fp_div: %d", self.fp_edges) logger.debug("fn edges after iso_fp_div: %d", self.fn_edges) - def get_report(self): """Long report diff --git a/linajea/gunpowder_nodes/add_movement_vectors.py b/linajea/gunpowder_nodes/add_movement_vectors.py index 0fe9047..f56bf37 100644 --- a/linajea/gunpowder_nodes/add_movement_vectors.py +++ b/linajea/gunpowder_nodes/add_movement_vectors.py @@ -54,7 +54,8 @@ def __init__( self.points = points self.array = array self.mask = mask - self.object_radius = np.array([object_radius]).flatten().astype(np.float32) + self.object_radius = np.array([object_radius]).flatten().astype( + np.float32) self.move_radius = move_radius if array_spec is None: self.array_spec = ArraySpec() @@ -98,7 +99,7 @@ def prepare(self, request): for i in range(1, len(context)): context[i] = max(context[i], self.move_radius) - logger.debug ("movement vector context %s", context) + logger.debug("movement vector context %s", context) # request points in a larger area points_roi = request[self.array].roi.grow( Coordinate(context), @@ -148,8 +149,10 @@ def process(self, batch, request): movement_vectors = Array( data=movement_vectors_data, spec=spec) - logger.debug("Cropping movement vectors to %s", request[self.array].roi) - batch.arrays[self.array] = movement_vectors.crop(request[self.array].roi) + logger.debug("Cropping movement vectors to %s", + request[self.array].roi) + batch.arrays[self.array] = movement_vectors.crop( + request[self.array].roi) # create mask and crop it to requested roi spec = self.spec[self.mask].copy() diff --git a/linajea/gunpowder_nodes/cast.py b/linajea/gunpowder_nodes/cast.py index afd203b..f3f02bc 100644 --- a/linajea/gunpowder_nodes/cast.py +++ b/linajea/gunpowder_nodes/cast.py @@ -1,8 +1,11 @@ """Provides a gunpowder node to cast array from one type to another """ +import copy + import gunpowder as gp import numpy as np + class Cast(gp.BatchFilter): """Gunpowder node to cast array to dtype diff --git a/linajea/gunpowder_nodes/normalize.py b/linajea/gunpowder_nodes/normalize.py index 01e088d..2840560 100644 --- a/linajea/gunpowder_nodes/normalize.py +++ b/linajea/gunpowder_nodes/normalize.py @@ -74,7 +74,8 @@ def process(self, batch, request): array = batch.arrays[self.array] array.spec.dtype = self.dtype array.data = array.data.astype(self.dtype) - array.data = (array.data - self.mapped_to_zero) / self.diff_mapped_to_one + array.data = ((array.data - self.mapped_to_zero) / + self.diff_mapped_to_one) array.data = array.data.astype(self.dtype) diff --git a/linajea/gunpowder_nodes/shift_augment.py b/linajea/gunpowder_nodes/shift_augment.py index 82334b1..3858fcb 100644 --- a/linajea/gunpowder_nodes/shift_augment.py +++ b/linajea/gunpowder_nodes/shift_augment.py @@ -7,7 +7,6 @@ from gunpowder.roi import Roi from gunpowder.coordinate import Coordinate -from gunpowder.batch_request import BatchRequest from gunpowder import BatchFilter logger = logging.getLogger(__name__) @@ -161,9 +160,9 @@ def process(self, batch, request): assert (request[array_key].roi.get_shape() == Coordinate(array.data.shape[-ndims:]) * self.lcm_voxel_size), \ - ("request roi shape {} is not the same as " - "generated array shape {}").format( - request[array_key].roi.get_shape(), array.data.shape) + ("request roi shape {} is not the same as " + "generated array shape {}").format( + request[array_key].roi.get_shape(), array.data.shape) batch[array_key] = array for points_key, points in batch.graphs.items(): @@ -250,8 +249,9 @@ def shift_points( for node in nodes: loc = node.location shift_axis_position = loc[shift_axis] - shift_array_index = int((shift_axis_position - shift_axis_start_pos) - // lcm_voxel_size[shift_axis]) + shift_array_index = int( + (shift_axis_position - shift_axis_start_pos) + // lcm_voxel_size[shift_axis]) assert(shift_array_index >= 0) shift = Coordinate(sub_shift_array[shift_array_index]) loc += shift diff --git a/linajea/gunpowder_nodes/tracks_source.py b/linajea/gunpowder_nodes/tracks_source.py index 5474db9..961b564 100644 --- a/linajea/gunpowder_nodes/tracks_source.py +++ b/linajea/gunpowder_nodes/tracks_source.py @@ -36,6 +36,7 @@ def __init__(self, id, location, parent_id, track_id, value=None): "value": value} super(TrackNode, self).__init__(id, location, attrs=attrs) + class TracksSource(BatchProvider): '''Gunpowder source node: read tracks of points from a csv file. @@ -91,7 +92,7 @@ def __init__(self, filename, points, points_spec=None, scale=1.0, self.points_spec = points_spec self.scale = scale if isinstance(use_radius, dict): - self.use_radius = {int(k):v for k,v in use_radius.items()} + self.use_radius = {int(k): v for k, v in use_radius.items()} else: self.use_radius = use_radius self.locations = None @@ -214,4 +215,4 @@ def _read_points(self): self.track_info = np.concatenate( (self.track_info, np.reshape(shuffled_norm_idcs, shuffled_norm_idcs.shape + (1,))), - axis=1, dtype=object) + axis=1, dtype=object) diff --git a/linajea/gunpowder_nodes/write_cells.py b/linajea/gunpowder_nodes/write_cells.py index 306d917..4d66a50 100644 --- a/linajea/gunpowder_nodes/write_cells.py +++ b/linajea/gunpowder_nodes/write_cells.py @@ -1,4 +1,5 @@ -"""Provides a gunpowder node to write node indicators and movement vectors to database +"""Provides a gunpowder node to write node indicators and movement vectors to +database """ import logging import pymongo @@ -155,7 +156,6 @@ def process(self, batch, request): logger.error(bwe.details) raise - def get_avg_mv(movement_vectors, index, edge_length): ''' Computes the average movement vector offset from the movement vectors in a cube centered at index. Accounts for the fact that each movement @@ -205,5 +205,5 @@ def get_avg_mv(movement_vectors, index, edge_length): offsets.append(offset_relative_to_c + relative_pos) logger.debug("Offsets to average: %s" + str(offsets)) movement_vector = tuple(float(sum(col) / len(col)) - for col in zip(*offsets)) + for col in zip(*offsets)) return movement_vector diff --git a/linajea/prediction/predict.py b/linajea/prediction/predict.py index e609a5a..3e1d7a9 100644 --- a/linajea/prediction/predict.py +++ b/linajea/prediction/predict.py @@ -2,9 +2,6 @@ Predicts cells/nodes and writes them to database """ -import warnings -warnings.filterwarnings("once", category=FutureWarning) - import argparse import logging import os @@ -79,7 +76,7 @@ def predict(config): filename_mask = os.path.join( sample, data_config['general'].get('mask_file', os.path.splitext( - data_config['general']['zarr_file'])[0] + "_mask.hdf")) + filename_data)[0] + "_mask.hdf")) z_range = data_config['general']['z_range'] if z_range[1] < 0: z_range[1] = data_config['general']['shape'][1] - z_range[1] @@ -112,17 +109,17 @@ def predict(config): source = normalize(source, config, raw, data_config) - inputs={ + inputs = { 'raw': raw } - outputs={ + outputs = { 0: cell_indicator, 1: maxima, } if not config.model.train_only_cell_indicator: outputs[3] = movement_vectors - dataset_names={ + dataset_names = { cell_indicator: 'volumes/cell_indicator', } if not config.model.train_only_cell_indicator: @@ -182,7 +179,6 @@ def predict(config): db_name=config.inference_data.data_source.db_name, db_host=config.general.db_host)) - roi_map = { raw: 'read_roi', cell_indicator: 'write_roi', @@ -200,13 +196,12 @@ def predict(config): roi_map=roi_map, num_workers=1, block_done_callback=lambda b, st, et: all([f(b) for f in cb]) - )) + )) with gp.build(pipeline): pipeline.request_batch(gp.BatchRequest()) - if __name__ == "__main__": parser = argparse.ArgumentParser() diff --git a/linajea/prediction/write_cells_from_zarr.py b/linajea/prediction/write_cells_from_zarr.py index 3dcea85..4e436d0 100644 --- a/linajea/prediction/write_cells_from_zarr.py +++ b/linajea/prediction/write_cells_from_zarr.py @@ -2,9 +2,6 @@ Writes cells/nodes from predicted zarr to database """ -import warnings -warnings.filterwarnings("once", category=FutureWarning) - import argparse import logging import os @@ -12,14 +9,14 @@ import h5py import numpy as np +import daisy import gunpowder as gp -from linajea.config import TrackingConfig +from linajea.config import (load_config, + TrackingConfig) from linajea.gunpowder_nodes import WriteCells from linajea.process_blockwise import write_done -from linajea.utils import (load_config, - construct_zarr_filename) - +from linajea.utils import construct_zarr_filename logger = logging.getLogger(__name__) @@ -43,7 +40,8 @@ def write_cells_from_zarr(config): movement_vectors = gp.ArrayKey('MOVEMENT_VECTORS') voxel_size = gp.Coordinate(config.inference_data.data_source.voxel_size) - output_size = gp.Coordinate(output_shape) * voxel_size + input_shape = config.model.predict_input_shape + output_size = gp.Coordinate(input_shape) * voxel_size chunk_request = gp.BatchRequest() chunk_request.add(cell_indicator, output_size) @@ -55,10 +53,16 @@ def write_cells_from_zarr(config): if os.path.isfile(os.path.join(sample, "data_config.toml")): data_config = load_config( os.path.join(sample, "data_config.toml")) + try: + filename_data = os.path.join( + sample, data_config['general']['data_file']) + except KeyError: + filename_data = os.path.join( + sample, data_config['general']['zarr_file']) filename_mask = os.path.join( sample, data_config['general'].get('mask_file', os.path.splitext( - data_config['general']['zarr_file'])[0] + "_mask.hdf")) + filename_data)[0] + "_mask.hdf")) z_range = data_config['general']['z_range'] if z_range[1] < 0: z_range[1] = data_config['general']['shape'][1] - z_range[1] @@ -127,7 +131,8 @@ def write_cells_from_zarr(config): WriteCells( maxima, cell_indicator, - movement_vectors if not config.model.train_only_cell_indicator else None, + movement_vectors if not config.model.train_only_cell_indicator + else None, score_threshold=config.inference_data.cell_score_threshold, db_host=config.general.db_host, db_name=config.inference_data.data_source.db_name, @@ -144,7 +149,7 @@ def write_cells_from_zarr(config): 'predict_db', config.inference_data.data_source.db_name, config.general.db_host) - )) + )) with gp.build(pipeline): pipeline.request_batch(gp.BatchRequest()) @@ -155,7 +160,6 @@ def write_cells_from_zarr(config): parser = argparse.ArgumentParser() parser.add_argument('--config', type=str, help='path to config file') - args = parser.parse_args() config = TrackingConfig.from_file(args.config) diff --git a/linajea/process_blockwise/daisy_check_functions.py b/linajea/process_blockwise/daisy_check_functions.py index 77c566a..adaeb63 100644 --- a/linajea/process_blockwise/daisy_check_functions.py +++ b/linajea/process_blockwise/daisy_check_functions.py @@ -5,6 +5,7 @@ """ import pymongo + def get_daisy_collection_name(step_name): return step_name + "_daisy" diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index b4c0010..00a9290 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -123,7 +123,6 @@ def predict_blockwise(linajea_config): logger.info("Sample: %s", data.datafile.filename) logger.info("DB: %s", data.db_name) - # process block-wise cf = [] if linajea_config.predict.write_to_zarr: diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index eba77e7..901c8ce 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -186,10 +186,14 @@ def solve_in_block(linajea_config, selected_keys = ['selected_' + str(pid) for pid in parameters_id] edge_attrs = selected_keys.copy() edge_attrs.extend(["prediction_distance", "distance"]) + join_collection = parameters["cell_state_key"] + logger.info("join collection %s", join_collection) + if join_collection.endswith("_"): + join_collection = join_collection[:-1] graph = graph_provider.get_graph( read_roi, edge_attrs=edge_attrs, - join_collection=parameters["cell_state_key"] + join_collection=join_collection ) # remove dangling nodes and edges diff --git a/linajea/run_scripts/01_train.py b/linajea/run_scripts/01_train.py index d49a8bf..d46e83b 100644 --- a/linajea/run_scripts/01_train.py +++ b/linajea/run_scripts/01_train.py @@ -30,7 +30,8 @@ logging.basicConfig( level=config.general.logging, handlers=[ - logging.FileHandler(os.path.join(config.general.setup_dir, 'run.log'), + logging.FileHandler(os.path.join(config.general.setup_dir, + 'run.log'), mode='a'), logging.StreamHandler(sys.stdout), ], diff --git a/linajea/run_scripts/04_solve.py b/linajea/run_scripts/04_solve.py index af3099f..4174983 100644 --- a/linajea/run_scripts/04_solve.py +++ b/linajea/run_scripts/04_solve.py @@ -34,9 +34,11 @@ parser.add_argument('--validate_on_train', action="store_true", help='validate on train data?') parser.add_argument('--val_param_id', type=int, default=None, - help='get test parameters from validation parameters_id') + help=('get test parameters from validation ' + 'parameters_id')) parser.add_argument('--param_id', type=int, default=None, - help='process parameters with parameters_id (e.g. resolve set of parameters)') + help=('process parameters with parameters_id ' + '(e.g. resolve set of parameters)')) parser.add_argument('--param_ids', default=None, nargs="+", help='start/end range or list of eval parameters_ids') parser.add_argument('--param_list_idx', type=str, default=None, diff --git a/linajea/run_scripts/05_evaluate.py b/linajea/run_scripts/05_evaluate.py index 9739d11..836661f 100644 --- a/linajea/run_scripts/05_evaluate.py +++ b/linajea/run_scripts/05_evaluate.py @@ -35,7 +35,8 @@ parser.add_argument('--validate_on_train', action="store_true", help='validate on train data?') parser.add_argument('--val_param_id', type=int, default=None, - help='get test parameters from validation parameters_id') + help=('get test parameters from validation ' + 'parameters_id')) parser.add_argument('--param_id', default=None, help='process parameters with parameters_id') parser.add_argument('--param_ids', default=None, nargs="+", diff --git a/linajea/run_scripts/06_run_best_config.py b/linajea/run_scripts/06_run_best_config.py index b730777..a09b62d 100644 --- a/linajea/run_scripts/06_run_best_config.py +++ b/linajea/run_scripts/06_run_best_config.py @@ -36,7 +36,8 @@ parser.add_argument('--swap_val_test', action="store_true", help='swap validation and test data?') parser.add_argument('--sort_by', type=str, default="sum_errors", - help='Which metric to use to select best parameters/weights') + help=('Which metric to use to select best ' + 'parameters/weights')) args = parser.parse_args() config = maybe_fix_config_paths_to_machine_and_load(args.config) @@ -44,8 +45,8 @@ results = {} args.validation = not args.swap_val_test - for sample_idx, inf_config in enumerate(getNextInferenceData(args, - is_evaluate=True)): + for sample_idx, inf_config in enumerate(getNextInferenceData( + args, is_evaluate=True)): sample = inf_config.inference_data.data_source.datafile.filename logger.debug("getting results for:", sample) @@ -62,7 +63,8 @@ del results['param_id'] solve_params = attr.fields_dict(SolveParametersConfig) - results = results.groupby(lambda x: str(x), dropna=False, as_index=False).agg( + results = results.groupby(lambda x: str(x), dropna=False, + as_index=False).agg( lambda x: -1 if len(x) != sample_idx+1 diff --git a/linajea/run_scripts/run.py b/linajea/run_scripts/run.py index 1f26863..01c9362 100755 --- a/linajea/run_scripts/run.py +++ b/linajea/run_scripts/run.py @@ -23,12 +23,14 @@ def backup_and_copy_file(source, target, fn): if os.path.exists(target_fn): os.makedirs(os.path.join(target, "backup"), exist_ok=True) shutil.copy2(target_fn, - os.path.join(target, "backup", fn + "_backup" + str(int(time.time())))) + os.path.join(target, "backup", + fn + "_backup" + str(int(time.time())))) if source is not None: source_fn = os.path.join(source, fn) if source_fn != target_fn: shutil.copy2(source_fn, target_fn) + def do_train(args, config, cmd): queue = config.train.job.queue num_gpus = 1 @@ -39,6 +41,7 @@ def do_train(args, config, cmd): queue, num_gpus, num_cpus, flags=flags) + def do_predict(args, config, cmd): queue = 'interactive' if args.interactive else 'local' num_gpus = 0 @@ -49,6 +52,7 @@ def do_predict(args, config, cmd): queue, num_gpus, num_cpus, flags=flags) + def do_extract_edges(args, config, cmd): queue = 'interactive' if args.interactive else config.extract.job.queue num_gpus = 0 @@ -59,6 +63,7 @@ def do_extract_edges(args, config, cmd): queue, num_gpus, num_cpus, flags=flags) + def do_solve(args, config, cmd, wait=True): queue = 'interactive' if args.interactive else config.solve.job.queue num_gpus = 0 @@ -96,12 +101,13 @@ def do_solve(args, config, cmd, wait=True): wait = False jobid = run_cmd(args, config, cmd, "solve", - queue, num_gpus, num_cpus, - array_limit=array_limit, - array_start=array_start, array_end=array_end, - flags=flags, wait=wait) + queue, num_gpus, num_cpus, + array_limit=array_limit, + array_start=array_start, array_end=array_end, + flags=flags, wait=wait) return jobid + def do_evaluate(args, config, cmd, jobid=None, wait=True): queue = 'interactive' if args.interactive else config.evaluate.job.queue num_gpus = 0 @@ -166,8 +172,8 @@ def run_cmd(args, config, cmd, job_name, print(cmd) print(' '.join(cmd)) - - if not args.array_job and not args.eval_array_job and (args.slurm or args.gridengine): + if not args.array_job and not args.eval_array_job and \ + (args.slurm or args.gridengine): if args.slurm: if num_gpus > 0: cmd = ['sbatch', '../run_slurm_gpu.sh'] + cmd[1:] @@ -195,7 +201,7 @@ def run_cmd(args, config, cmd, job_name, jobid = None if not args.local: - bsub_stdout_regex = re.compile("Job <(\d+)> is submitted*") + bsub_stdout_regex = re.compile(r"Job <(\d+)> is submitted*") logger.debug("Command output: %s" % output) print(output.stdout) print(output.stderr) @@ -247,7 +253,8 @@ def run_cmd(args, config, cmd, job_name, parser.add_argument("--interactive", action="store_true", help='run on interactive node on cluster?') parser.add_argument('--array_job', action="store_true", - help='submit each parameter set for solving/eval as one job?') + help=('submit each parameter set for ' + 'solving/eval as one job?')) parser.add_argument('--eval_array_job', action="store_true", help='submit each parameter set for eval as one job?') parser.add_argument('--param_ids', default=None, nargs=2, @@ -258,7 +265,6 @@ def run_cmd(args, config, cmd, job_name, action="store_false", help='block after starting eval jobs?') - args = parser.parse_args() config = maybe_fix_config_paths_to_machine_and_load(args.config) config = TrackingConfig(**config) @@ -269,13 +275,13 @@ def run_cmd(args, config, cmd, job_name, os.makedirs(setup_dir, exist_ok=True) os.makedirs(os.path.join(setup_dir, "tmp_configs"), exist_ok=True) - if not is_new_run and \ - os.path.dirname(os.path.abspath(args.config)) != \ - os.path.abspath(setup_dir) and \ + if not is_new_run: + config_dir = os.path.dirname(os.path.abspath(args.config)) + if config_dir != os.path.abspath(setup_dir) and \ "tmp_configs" not in args.config: - raise RuntimeError( - "overwriting config with external config file (%s - %s)", - args.config, setup_dir) + raise RuntimeError( + "overwriting config with external config file (%s - %s)", + args.config, setup_dir) config_file = os.path.basename(args.config) if "tmp_configs" not in args.config: backup_and_copy_file(os.path.dirname(args.config), @@ -308,7 +314,6 @@ def run_cmd(args, config, cmd, job_name, if args.run_evaluate: run_steps.append("05_evaluate.py") - config.path = os.path.join("tmp_configs", "config_{}.toml".format( time.time())) config_dict = attr.asdict(config) @@ -340,7 +345,8 @@ def run_cmd(args, config, cmd, job_name, elif step == "04_solve.py": jobid = do_solve(args, config, cmd) elif step == "05_evaluate.py": - do_evaluate(args, config, cmd, jobid=jobid, wait=args.block_after_eval) + do_evaluate(args, config, cmd, jobid=jobid, + wait=args.block_after_eval) else: raise RuntimeError("invalid processing step! %s", step) diff --git a/linajea/tracking/constraints.py b/linajea/tracking/constraints.py index 00e7dd1..5d4a36b 100644 --- a/linajea/tracking/constraints.py +++ b/linajea/tracking/constraints.py @@ -43,7 +43,7 @@ def ensure_one_predecessor(node, indicators, graph, **kwargs): # plus "appear" constraint_prev.set_coefficient(indicators["node_appear"][node], 1) - #node + # node constraint_prev.set_coefficient(indicators["node_selected"][node], -1) # relation, value diff --git a/linajea/tracking/cost_functions.py b/linajea/tracking/cost_functions.py index 01d2e53..7f05ac9 100644 --- a/linajea/tracking/cost_functions.py +++ b/linajea/tracking/cost_functions.py @@ -8,6 +8,7 @@ logger = logging.getLogger(__name__) + def score_times_weight_plus_th_costs_fn(weight, threshold, key="score", feature_func=lambda x: x): @@ -83,7 +84,7 @@ def is_close(obj): def get_node_indicator_fn_map_default(config, parameters, graph): if parameters.feature_func == "noop": - feature_func = lambda x: x + feature_func = lambda x: x # noqa: E731 elif parameters.feature_func == "log": feature_func = np.log elif parameters.feature_func == "square": @@ -129,7 +130,7 @@ def get_node_indicator_fn_map_default(config, parameters, graph): def get_edge_indicator_fn_map_default(config, parameters): if parameters.feature_func == "noop": - feature_func = lambda x: x + feature_func = lambda x: x # noqa: E731 elif parameters.feature_func == "log": feature_func = np.log elif parameters.feature_func == "square": @@ -146,7 +147,7 @@ def get_edge_indicator_fn_map_default(config, parameters): feature_func=feature_func) } if solver_type == "basic": - pass + pass else: logger.info("solver_type %s unknown for edge indicators, skipping", solver_type) diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index ca52568..9baa25f 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -52,11 +52,12 @@ def __init__(self, track_graph, def update_objective(self, node_indicator_fn_map, edge_indicator_fn_map, selected_key): + assert ( + set(node_indicator_fn_map.keys()) == self.node_indicator_keys and + set(edge_indicator_fn_map.keys()) == self.edge_indicator_keys), \ + "cannot change set of indicators during one run!" self.node_indicator_fn_map = node_indicator_fn_map self.edge_indicator_fn_map = edge_indicator_fn_map - assert (set(self.node_indicator_fn_map.keys()) == self.node_indicator_keys and - set(self.edge_indicator_fn_map.keys()) == self.edge_indicator_keys), \ - "cannot change set of indicators during one run!" self.selected_key = selected_key @@ -86,7 +87,8 @@ def solve(self): return solution - def solve_and_set(self, node_key="node_selected", edge_key="edge_selected"): + def solve_and_set(self, node_key="node_selected", + edge_key="edge_selected"): solution = self.solve() for v in self.graph.nodes: @@ -123,12 +125,14 @@ def _set_objective(self): # node costs for k, fn in self.node_indicator_fn_map.items(): for n_id, node in self.graph.nodes(data=True): - objective.set_coefficient(self.indicators[k][n_id], sum(fn(node))) + objective.set_coefficient(self.indicators[k][n_id], + sum(fn(node))) # edge costs for k, fn in self.edge_indicator_fn_map.items(): for u, v, edge in self.graph.edges(data=True): - objective.set_coefficient(self.indicators[k][(u, v)], sum(fn(edge))) + objective.set_coefficient(self.indicators[k][(u, v)], + sum(fn(edge))) self.objective = objective @@ -142,7 +146,6 @@ def _create_constraints(self): for t in range(self.graph.begin, self.graph.end): self._add_inter_frame_constraints(t) - def _add_pin_constraints(self): logger.debug("setting pin constraints: %s", @@ -175,7 +178,6 @@ def _add_node_constraints(self): for fn in self.node_constraints_fn_list: self.main_constraints.extend(fn(node, self.indicators)) - def _add_inter_frame_constraints(self, t): '''Linking constraints from t to t+1.''' @@ -185,7 +187,8 @@ def _add_inter_frame_constraints(self, t): for node in self.graph.cells_by_frame(t): for fn in self.inter_frame_constraints_fn_list: self.main_constraints.extend( - fn(node, self.indicators, self.graph, pinned_edges=self.pinned_edges)) + fn(node, self.indicators, self.graph, + pinned_edges=self.pinned_edges)) class BasicSolver(Solver): diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index cc5f509..35d69b4 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -5,7 +5,6 @@ import os import time -import numpy as np import pylp from .solver import Solver @@ -68,7 +67,7 @@ def track(graph, config, selected_key, frame_key='t', pin_constraints_fn_list (list of Callable) - A list of Callable that return a list of pylp.LinearConstraint each. + List of Callable that return a list of pylp.LinearConstraint each. Use this to add constraints to pin edge indicators to specific states. Called only if edge has already been set by neighboring blocks. @@ -79,7 +78,7 @@ def track(graph, config, selected_key, frame_key='t', edge_constraints_fn_list (list of Callable) - A list of Callable that return a list of pylp.LinearConstraint each. + List of Callable that return a list of pylp.LinearConstraint each. Use this to add constraints on a specific edge. Interface: fn(edge, indicators) edge: Create constraints for this edge @@ -87,7 +86,7 @@ def track(graph, config, selected_key, frame_key='t', node_constraints_fn_list (list of Callable) - A list of Callable that return a list of pylp.LinearConstraint each. + List of Callable that return a list of pylp.LinearConstraint each. Use this to add constraints on a specific node. Interface: fn(node, indicators) node: Create constraints for this node @@ -95,7 +94,7 @@ def track(graph, config, selected_key, frame_key='t', inter_frame_constraints_fn_list (list of Callable) - A list of Callable that return a list of pylp.LinearConstraint each. + List of Callable that return a list of pylp.LinearConstraint each. d Interface: fn(node, indicators, graph, **kwargs) node: Create constraints for this node @@ -130,7 +129,6 @@ def track(graph, config, selected_key, frame_key='t', solver = None total_solve_time = 0 - if config.solve.solver_type is not None: constrs = constraints.get_constraints_default(config) pin_constraints_fn_list = constrs[0] @@ -184,10 +182,11 @@ def track(graph, config, selected_key, frame_key='t', def write_struct_svm(solver, block_id, output_dir): os.makedirs(output_dir, exist_ok=True) + block_id = str(block_id) # write all indicators as "object_id, indicator_id" for k, objs in solver.indicators.items(): - with open(os.path.join(output_dir, k + "_b" + str(block_id)), 'w') as f: + with open(os.path.join(output_dir, k + "_b" + block_id), 'w') as f: for obj, v in objs.items(): f.write(f"{obj} {v}\n") @@ -215,7 +214,7 @@ def write_struct_svm(solver, block_id, output_dir): num_features = acc assert sorted(indicators.keys()) == list(range(len(indicators))), \ "some error reading indicators and features" - with open(os.path.join(output_dir, "features_b" + str(block_id)), 'w') as f: + with open(os.path.join(output_dir, "features_b" + block_id), 'w') as f: for ind in sorted(indicators.keys()): k, costs = indicators[ind] features = [0]*num_features @@ -234,10 +233,11 @@ def rel_to_str(rel): else: raise RuntimeError("invalid pylp.Relation: %s", rel) - with open(os.path.join(output_dir, "constraints_b" + str(block_id)), 'w') as f: + with open(os.path.join(output_dir, "constraints_b" + block_id), 'w') as f: for constraint in solver.main_constraints: val = constraint.get_value() rel = rel_to_str(constraint.get_relation()) - coeffs = " ".join([f"{v}*{idx}" - for idx, v in constraint.get_coefficients().items()]) + coeffs = " ".join( + [f"{v}*{idx}" + for idx, v in constraint.get_coefficients().items()]) f.write(f"{coeffs} {rel} {val}\n") diff --git a/linajea/training/torch_loss.py b/linajea/training/torch_loss.py index 0746d0f..591fbe9 100644 --- a/linajea/training/torch_loss.py +++ b/linajea/training/torch_loss.py @@ -22,7 +22,7 @@ def weighted_mse_loss(inputs, target, weight): ws = weight.sum() * inputs.size()[0] / weight.size()[0] if abs(ws) <= 0.001: ws = 1 - return (weight * ((inputs - target) ** 2)).sum()/ ws + return (weight * ((inputs - target) ** 2)).sum() / ws def weighted_mse_loss2(inputs, target, weight): return (weight * ((inputs - target) ** 2)).mean() @@ -59,7 +59,6 @@ def metric_summaries(self, maxima_in_cell_mask, output_shape_2): - # ground truth cell locations gt_max_loc = torch.nonzero(gt_cell_center > 0.5) @@ -163,7 +162,6 @@ def metric_summaries(self, self.summaries['par_vec_diff_mn_pred'][0] = par_vec_diff_mn_pred self.summaries['par_vec_tpr_pred'][0] = par_vec_tpr_pred - def forward(self, *, gt_cell_indicator, cell_indicator, @@ -239,7 +237,8 @@ def forward(self, *, self.config.model.cell_indicator_weighted = 0.00001 cond = gt_cell_indicator < self.config.model.cell_indicator_cutoff weight = torch.where(cond, - self.config.model.cell_indicator_weighted, 1.0) + self.config.model.cell_indicator_weighted, + 1.0) else: weight = torch.tensor(1.0) @@ -249,8 +248,8 @@ def forward(self, *, # l=1, d, h, w cell_indicator, # l=1, d, h, w - weight) # cell_mask) + weight) if self.config.model.train_only_cell_indicator: loss = cell_indicator_loss @@ -259,12 +258,11 @@ def forward(self, *, if self.config.train.movement_vectors_loss_transition_offset: # smooth transition from training movement vectors on complete # cell mask to only on maxima - # https://www.wolframalpha.com/input/?i=1.0%2F(1.0+%2B+exp(0.01*(-x%2B20000)))+x%3D0+to+40000 - alpha = (1.0 / - (1.0 + torch.exp( - self.config.train.movement_vectors_loss_transition_factor * - (-self.current_step + - self.config.train.movement_vectors_loss_transition_offset)))) + # https://www.wolframalpha.com/input/? + # i=1.0%2F(1.0+%2B+exp(0.01*(-x%2B20000)))+x%3D0+to+40000 + f = self.config.train.movement_vectors_loss_transition_factor + o = self.config.train.movement_vectors_loss_transition_offset + alpha = (1.0 / (1.0 + torch.exp(f * (-self.current_step + o)))) self.summaries['alpha'][0] = alpha movement_vectors_loss = ( diff --git a/linajea/training/torch_model.py b/linajea/training/torch_model.py index 2921b3b..d40bb35 100644 --- a/linajea/training/torch_model.py +++ b/linajea/training/torch_model.py @@ -9,7 +9,7 @@ from funlib.learn.torch.models import UNet, ConvPass from .utils import (crop, - crop_to_factor) + crop_to_factor) logger = logging.getLogger(__name__) @@ -45,7 +45,8 @@ def __init__(self, config, current_step=0): elif config.model.unet_style == "multihead": num_heads = 2 else: - raise RuntimeError("invalid unet style, should be split, single or multihead") + raise RuntimeError("invalid unet style, should be split, single " + "or multihead") self.unet_cell_ind = UNet( in_channels=1, @@ -78,7 +79,8 @@ def __init__(self, config, current_step=0): self.parent_vectors_batched = ConvPass(num_fmaps[1], 3, [[1, 1, 1]], activation=None) - self.nms = torch.nn.MaxPool3d(config.model.nms_window_shape, stride=1, padding=0) + self.nms = torch.nn.MaxPool3d(config.model.nms_window_shape, stride=1, + padding=0) def init_layers(self): # the default init in pytorch is a bit strange @@ -93,6 +95,7 @@ def init_weights(m): # torch.nn.init.xavier_uniform(m.weight) torch.nn.init.kaiming_normal_(m.weight, nonlinearity='relu') m.bias.data.fill_(0.0) + # activation func sigmoid def init_weights_sig(m): if isinstance(m, torch.nn.Conv3d): @@ -111,8 +114,9 @@ def inout_shapes(self, device): input_shape_predict = self.config.model.predict_input_shape self.eval() with torch.no_grad(): - trial_run_predict = self.forward(torch.zeros(input_shape_predict, - dtype=torch.float32).to(device)) + trial_run_predict = self.forward( + torch.zeros(input_shape_predict, + dtype=torch.float32).to(device)) self.train() logger.info("test done") if self.config.model.train_only_cell_indicator: @@ -129,7 +133,8 @@ def inout_shapes(self, device): json.dump(net_config, f) input_shape = self.config.model.train_input_shape - trial_run = self.forward(torch.zeros(input_shape, dtype=torch.float32).to(device)) + trial_run = self.forward(torch.zeros(input_shape, + dtype=torch.float32).to(device)) if self.config.model.train_only_cell_indicator: trial_ci, _, trial_max = trial_run else: @@ -147,31 +152,33 @@ def inout_shapes(self, device): json.dump(net_config, f) return input_shape, output_shape_1, output_shape_2 - def forward(self, raw): if self.config.model.latent_temp_conv: raw = torch.reshape(raw, [raw.size()[0], 1] + list(raw.size())[1:]) else: raw = torch.reshape(raw, [1, 1] + list(raw.size())) - model_out_1 = self.unet_cell_ind(raw) + model_out = self.unet_cell_ind(raw) if self.config.model.unet_style != "multihead" or \ self.config.model.train_only_cell_indicator: - model_out_1 = [model_out_1] - cell_indicator_batched = self.cell_indicator_batched(model_out_1[0]) + model_out = [model_out] + cell_indicator_batched = self.cell_indicator_batched(model_out[0]) output_shape_1 = list(cell_indicator_batched.size())[1:] cell_indicator = torch.reshape(cell_indicator_batched, output_shape_1) if self.config.model.unet_style == "single": - parent_vectors_batched = self.parent_vectors_batched(model_out_1[0]) - parent_vectors = torch.reshape(parent_vectors_batched, [3] + output_shape_1) - elif self.config.model.unet_style == "split" and \ - not self.config.model.train_only_cell_indicator: + parent_vectors_batched = self.parent_vectors_batched(model_out[0]) + parent_vectors = torch.reshape(parent_vectors_batched, + [3] + output_shape_1) + elif (self.config.model.unet_style == "split" and + not self.config.model.train_only_cell_indicator): model_par_vec = self.unet_par_vec(raw) parent_vectors_batched = self.parent_vectors_batched(model_par_vec) - parent_vectors = torch.reshape(parent_vectors_batched, [3] + output_shape_1) - else: # self.config.model.unet_style == "multihead" - parent_vectors_batched = self.parent_vectors_batched(model_out_1[1]) - parent_vectors = torch.reshape(parent_vectors_batched, [3] + output_shape_1) + parent_vectors = torch.reshape(parent_vectors_batched, + [3] + output_shape_1) + else: # self.config.model.unet_style == "multihead" + parent_vectors_batched = self.parent_vectors_batched(model_out[1]) + parent_vectors = torch.reshape(parent_vectors_batched, + [3] + output_shape_1) maxima = self.nms(cell_indicator_batched) if not self.training: diff --git a/linajea/training/train.py b/linajea/training/train.py index 5506962..aa08062 100644 --- a/linajea/training/train.py +++ b/linajea/training/train.py @@ -2,9 +2,6 @@ Create model and train """ -import warnings - - import logging import time import os @@ -80,7 +77,8 @@ def train(config): "your model to device in the main process." ) from e - input_shape, output_shape_1, output_shape_2 = model.inout_shapes(device=device) + input_shape, output_shape_1, output_shape_2 = model.inout_shapes( + device=device) logger.debug("Model: %s", model) voxel_size = gp.Coordinate(config.train_data.data_sources[0].voxel_size) @@ -122,7 +120,6 @@ def train(config): snapshot_request.add(grad_movement_vectors, output_size_1) logger.debug("Snapshot request: %s" % str(snapshot_request)) - train_sources = get_sources(config, raw, anchor, tracks, center_tracks, config.train_data.data_sources, val=False) @@ -135,6 +132,7 @@ def train(config): # load data source and # choose augmentations depending on config augment = config.train.augment + use_fast_points_transform = augment.elastic.use_fast_points_transform train_pipeline = ( tuple(train_sources) + gp.RandomProvider() + @@ -146,24 +144,24 @@ def train(config): augment.elastic.rotation_max*np.pi/180.0], rotation_3d=augment.elastic.rotation_3d, subsample=augment.elastic.subsample, - use_fast_points_transform=augment.elastic.use_fast_points_transform, + use_fast_points_transform=use_fast_points_transform, spatial_dims=3, - temporal_dim=True) \ + temporal_dim=True) if augment.elastic is not None else NoOp()) + (ShiftAugment( prob_slip=augment.shift.prob_slip, prob_shift=augment.shift.prob_shift, sigma=augment.shift.sigma, - shift_axis=0) \ + shift_axis=0) if augment.shift is not None else NoOp()) + - (ShuffleChannels(raw) \ + (ShuffleChannels(raw) if augment.shuffle_channels else NoOp()) + (gp.SimpleAugment( mirror_only=augment.simple.mirror, - transpose_only=augment.simple.transpose) \ + transpose_only=augment.simple.transpose) if augment.simple is not None else NoOp()) + (gp.ZoomAugment( @@ -171,7 +169,7 @@ def train(config): factor_max=augment.zoom.factor_max, spatial_dims=augment.zoom.spatial_dims, order={raw: 1, - }) \ + }) if augment.zoom is not None else NoOp()) + (gp.NoiseAugment( @@ -179,7 +177,7 @@ def train(config): mode='gaussian', var=augment.noise_gaussian.var, clip=False, - check_val_range=False) \ + check_val_range=False) if augment.noise_gaussian is not None else NoOp()) + (gp.NoiseAugment( @@ -187,7 +185,7 @@ def train(config): mode='speckle', var=augment.noise_speckle.var, clip=False, - check_val_range=False) \ + check_val_range=False) if augment.noise_speckle is not None else NoOp()) + (gp.NoiseAugment( @@ -195,7 +193,7 @@ def train(config): mode='s&p', amount=augment.noise_saltpepper.amount, clip=False, - check_val_range=False) \ + check_val_range=False) if augment.noise_saltpepper is not None else NoOp()) + (gp.HistogramAugment( @@ -203,8 +201,8 @@ def train(config): # raw_tmp, range_low=augment.histogram.range_low, range_high=augment.histogram.range_high, - z_section_wise=False) \ - if augment.histogram is not None else NoOp()) + + z_section_wise=False) + if augment.histogram is not None else NoOp()) + (gp.IntensityAugment( raw, @@ -213,7 +211,7 @@ def train(config): shift_min=augment.intensity.shift[0], shift_max=augment.intensity.shift[1], z_section_wise=False, - clip=False) \ + clip=False) if augment.intensity is not None else NoOp()) + (AddMovementVectors( @@ -223,14 +221,13 @@ def train(config): array_spec=gp.ArraySpec(voxel_size=voxel_size), radius=config.train.object_radius, move_radius=config.train.move_radius, - dense=not config.general.sparse) \ + dense=not config.general.sparse) if not config.model.train_only_cell_indicator else NoOp()) + (gp.Reject( ensure_nonempty=tracks, mask=cell_mask, - min_masked=0.0001, - ) \ + min_masked=0.0001) if config.general.sparse else NoOp()) + gp.RasterizeGraph( @@ -252,7 +249,7 @@ def train(config): (gp.PreCache( cache_size=config.train.cache_size, - num_workers=config.train.job.num_workers) \ + num_workers=config.train.job.num_workers) if config.train.job.num_workers > 1 else NoOp()) ) @@ -269,15 +266,14 @@ def train(config): array_spec=gp.ArraySpec(voxel_size=voxel_size), radius=config.train.object_radius, move_radius=config.train.move_radius, - dense=not config.general.sparse) \ + dense=not config.general.sparse) if not config.model.train_only_cell_indicator else NoOp()) + (gp.Reject( ensure_nonempty=tracks, mask=cell_mask, min_masked=0.0001, - reject_probability=augment.reject_empty_prob - ) \ + reject_probability=augment.reject_empty_prob) if config.general.sparse else NoOp()) + gp.Reject( @@ -305,7 +301,7 @@ def train(config): (gp.PreCache( cache_size=config.train.cache_size, - num_workers=1) \ + num_workers=1) if config.train.job.num_workers > 1 else NoOp()) ) @@ -317,15 +313,14 @@ def train(config): else: pipeline = train_pipeline - - inputs={ + inputs = { 'raw': raw, } if not config.model.train_only_cell_indicator: inputs['cell_mask'] = cell_mask inputs['gt_movement_vectors'] = movement_vectors - outputs={ + outputs = { 0: pred_cell_indicator, 1: maxima, 2: raw_cropped, @@ -333,7 +328,7 @@ def train(config): if not config.model.train_only_cell_indicator: outputs[3] = pred_movement_vectors - loss_inputs={ + loss_inputs = { 'gt_cell_indicator': cell_indicator, 'cell_indicator': pred_cell_indicator, 'maxima': maxima, @@ -364,8 +359,10 @@ def train(config): if not config.model.train_only_cell_indicator: snapshot_datasets[cell_mask] = 'volumes/cell_mask' snapshot_datasets[movement_vectors] = 'volumes/movement_vectors' - snapshot_datasets[pred_movement_vectors] = 'volumes/pred_movement_vectors' - snapshot_datasets[grad_movement_vectors] = 'volumes/grad_movement_vectors' + snapshot_datasets[pred_movement_vectors] = \ + 'volumes/pred_movement_vectors' + snapshot_datasets[grad_movement_vectors] = \ + 'volumes/grad_movement_vectors' if logger.isEnabledFor(logging.DEBUG): logger.debug("requires_grad enabled for:") @@ -413,13 +410,14 @@ def train(config): # visualize gp.Snapshot(snapshot_datasets, - output_dir=os.path.join(config.general.setup_dir, 'snapshots'), - output_filename='snapshot_{iteration}.hdf', - additional_request=snapshot_request, - every=config.train.snapshot_stride, - dataset_dtypes={ - maxima: np.float32 - }) + + output_dir=os.path.join(config.general.setup_dir, + 'snapshots'), + output_filename='snapshot_{iteration}.hdf', + additional_request=snapshot_request, + every=config.train.snapshot_stride, + dataset_dtypes={ + maxima: np.float32 + }) + gp.PrintProfilingStats(every=config.train.profiling_stride) ) @@ -487,11 +485,11 @@ def get_sources(config, raw, anchor, tracks, center_tracks, data_sources, filename_divisions = os.path.join( d, data_config['general']['divisions_file']) except KeyError: - logger.warning("Cannot find divisions_file in data_config, " + logger.warning("Cannot find divisions_file in data_config," "falling back to using tracks_file" - "(usually ok unless they are not included and " - "there is a separate file containing the " - "divisions)") + "(usually ok unless they are not included " + "and there is a separate file containing " + "the divisions)") filename_divisions = os.path.join( d, data_config['general']['tracks_file']) filename_daughters = os.path.join( @@ -542,6 +540,7 @@ def get_sources(config, raw, anchor, tracks, center_tracks, data_sources, random_location = gp.RandomLocation args = [raw] + point_balance_radius = config.train.augment.point_balance_radius track_source = ( merge_sources( file_source, @@ -555,7 +554,7 @@ def get_sources(config, raw, anchor, tracks, center_tracks, data_sources, *args, ensure_nonempty=center_tracks, p_nonempty=config.train.augment.reject_empty_prob, - point_balance_radius=config.train.augment.point_balance_radius) + point_balance_radius=point_balance_radius) ) # if division nodes should be sampled more often @@ -573,7 +572,7 @@ def get_sources(config, raw, anchor, tracks, center_tracks, data_sources, *args, ensure_nonempty=center_tracks, p_nonempty=config.train.augment.reject_empty_prob, - point_balance_radius=config.train.augment.point_balance_radius) + point_balance_radius=point_balance_radius) ) track_source = (track_source, div_source) + \ @@ -586,7 +585,6 @@ def get_sources(config, raw, anchor, tracks, center_tracks, data_sources, return sources - def merge_sources( raw, tracks, @@ -606,7 +604,7 @@ def merge_sources( (raw, # tracks TracksSource( - track_file, + track_file, tracks, points_spec=gp.PointsSpec(roi=roi), scale=scale, diff --git a/linajea/training/utils.py b/linajea/training/utils.py index a7e5cf3..ca6c68c 100644 --- a/linajea/training/utils.py +++ b/linajea/training/utils.py @@ -1,7 +1,7 @@ """Utility functions for training process """ -import copy import glob +import logging import math import re @@ -9,6 +9,12 @@ import gunpowder as gp +from linajea.gunpowder_nodes import (Clip, + NormalizeAroundZero, + NormalizeLowerUpper) + +logger = logging.getLogger(__name__) + def get_latest_checkpoint(basename): """Looks for the checkpoint with the highest step count @@ -33,7 +39,7 @@ def atoi(text): return int(text) if text.isdigit() else text def natural_keys(text): - return [ atoi(c) for c in re.split(r'(\d+)', text) ] + return [atoi(c) for c in re.split(r'(\d+)', text)] checkpoints = glob.glob(basename + '_checkpoint_*') checkpoints.sort(key=natural_keys) @@ -191,7 +197,8 @@ def normalize(pipeline, config, raw, data_config=None): logger.info("mean normalization %s %s %s %s", mean, std, mn, mx) pipeline = pipeline + \ Clip(raw, mn=mn, mx=mx) + \ - NormalizeAroundZero(raw, mapped_to_zero=mean, diff_mapped_to_one=std) + NormalizeAroundZero(raw, mapped_to_zero=mean, + diff_mapped_to_one=std) elif config.train.normalization.type == 'median': median = data_config['stats']['median'] mad = data_config['stats']['mad'] @@ -200,7 +207,8 @@ def normalize(pipeline, config, raw, data_config=None): logger.info("median normalization %s %s %s %s", median, mad, mn, mx) pipeline = pipeline + \ Clip(raw, mn=mn, mx=mx) + \ - NormalizeAroundZero(raw, mapped_to_zero=median, diff_mapped_to_one=mad) + NormalizeAroundZero(raw, mapped_to_zero=median, + diff_mapped_to_one=mad) else: raise RuntimeError("invalid normalization method %s", config.train.normalization.type) diff --git a/linajea/utils/candidate_database.py b/linajea/utils/candidate_database.py index 0a57fe7..d87f02f 100644 --- a/linajea/utils/candidate_database.py +++ b/linajea/utils/candidate_database.py @@ -204,20 +204,21 @@ def get_parameters_id_round( if "context" not in entry: continue for param in params: - if isinstance(query[param], float) or \ - (isinstance(query[param], int) and - not isinstance(query[param], bool)): + if (isinstance(query[param], float) or + (isinstance(query[param], int) and + not isinstance(query[param], bool))): if round(entry[param], 10) != round(query[param], 10): break elif isinstance(query[param], dict): - if param == 'roi' and \ - (entry['roi']['offset'] != query['roi']['offset'] or \ - entry['roi']['shape'] != query['roi']['shape']): - break + if param == 'roi': + e_roi = entry['roi'] + q_roi = query['roi'] + if (e_roi['offset'] != q_roi['offset'] or + e_roi['shape'] != q_roi['shape']): + break elif param == 'cell_state_key': - if '$exists' in query['cell_state_key'] and \ - query['cell_state_key']['$exists'] == False and \ - 'cell_state_key' in entry: + if query[param].get("$exists") is False and \ + param in entry: break elif isinstance(query[param], str): if param not in entry or \ @@ -383,26 +384,6 @@ def get_scores(self, filters=None, eval_params=None): logger.info("Query: %s", query) scores = list(score_collection.find(query)) logger.info("Found %d scores" % len(scores)) - if len(scores) == 0: - if "fn_div_count_unconnected_parent" in query and \ - query["fn_div_count_unconnected_parent"] == True: - del query["fn_div_count_unconnected_parent"] - if "validation_score" in query and \ - query["validation_score"] == False: - del query["validation_score"] - if "window_size" in query and \ - query["window_size"] == 50: - del query["window_size"] - if "filter_short_tracklets_len" in query and \ - query["filter_short_tracklets_len"] == -1: - del query["filter_short_tracklets_len"] - if "ignore_one_off_div_errors" in query and \ - query["ignore_one_off_div_errors"] == False: - del query["ignore_one_off_div_errors"] - logger.info("Query: %s", query) - scores = list(score_collection.find(query)) - logger.info("Found %d scores" % len(scores)) - finally: self._MongoDbGraphProvider__disconnect() return scores @@ -437,7 +418,9 @@ def write_score(self, parameters_id, report, eval_params=None): query.update(eval_params.valid()) cnt = score_collection.count_documents(query) - assert cnt <= 1, "multiple scores for query %s exist, don't know which to overwrite" % query + assert cnt <= 1, ( + "multiple scores for query %s exist, don't know which to " + "overwrite" % query) if parameters is None: parameters = {} diff --git a/linajea/utils/check_or_create_db.py b/linajea/utils/check_or_create_db.py index b25e032..76a4328 100644 --- a/linajea/utils/check_or_create_db.py +++ b/linajea/utils/check_or_create_db.py @@ -78,7 +78,8 @@ def _checkOrCreateDBMeta(db_host, db_meta_info, prefix="linajea_", assert query_result == 1 query_result = db["db_meta_info"].find_one() del query_result["_id"] - if query_result == db_meta_info or query_result == db_meta_info_no_roi: + if query_result == db_meta_info or \ + query_result == db_meta_info_no_roi: logger.info("{}: {} (accessed)".format(db_name, query_result)) break else: diff --git a/linajea/utils/construct_zarr_filename.py b/linajea/utils/construct_zarr_filename.py index cb83953..3e1c04a 100644 --- a/linajea/utils/construct_zarr_filename.py +++ b/linajea/utils/construct_zarr_filename.py @@ -8,6 +8,8 @@ def construct_zarr_filename(config, sample, checkpoint): config.predict.output_zarr_dir, config.general.setup, os.path.basename(sample) + - 'predictions' + (config.general.tag if config.general.tag is not None else "") + + 'predictions' + (config.general.tag if config.general.tag is not None + else "") + str(checkpoint) + "_" + - str(config.inference_data.cell_score_threshold).replace(".", "_") + '.zarr') + str(config.inference_data.cell_score_threshold).replace(".", "_") + + '.zarr') diff --git a/linajea/utils/get_next_inference_data.py b/linajea/utils/get_next_inference_data.py index c867247..445275d 100644 --- a/linajea/utils/get_next_inference_data.py +++ b/linajea/utils/get_next_inference_data.py @@ -80,17 +80,14 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): if hasattr(args, "checkpoint") and args.checkpoint > 0: checkpoints = [args.checkpoint] - try: - max_cell_move = max(config.extract.edge_move_threshold.values()) - except: - max_cell_move = None + max_cell_move = max(config.extract.edge_move_threshold.values()) for pid in range(len(config.solve.parameters)): if config.solve.parameters[pid].max_cell_move is None: assert max_cell_move is not None, ( "Please provide a max_cell_move value, either as " - "extract.edge_move_threshold or directly in the parameter sets " - "in solve.parameters! (What is the maximum distance that a cell " - "can move between two adjacent frames?)") + "extract.edge_move_threshold or directly in the parameter " + "sets in solve.parameters! (What is the maximum distance that " + "a cell can move between two adjacent frames?)") config.solve.parameters[pid].max_cell_move = max_cell_move os.makedirs("tmp_configs", exist_ok=True) @@ -109,9 +106,10 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): args.val_param_id is not None: config = _fix_val_param_pid(args, config, checkpoint) solve_parameters_sets = deepcopy(config.solve.parameters) - if hasattr(args, "param_id") and (is_solve or is_evaluate) and \ - (args.param_id is not None or - (hasattr(args, "param_ids") and args.param_ids is not None)): + if (hasattr(args, "param_id") and + (is_solve or is_evaluate) and + (args.param_id is not None or + (hasattr(args, "param_ids") and args.param_ids is not None))): config = _fix_param_pid(args, config, checkpoint, inference_data) solve_parameters_sets = deepcopy(config.solve.parameters) inference_data_tmp = { @@ -130,7 +128,8 @@ def getNextInferenceData(args, is_solve=False, is_evaluate=False): roi=attr.asdict(sample.roi), tag=config.general.tag) inference_data_tmp['data_source'] = sample - config.inference_data = InferenceDataTrackingConfig(**inference_data_tmp) # type: ignore + config.inference_data = InferenceDataTrackingConfig( + **inference_data_tmp) # type: ignore if is_solve: config = _fix_solve_roi(config) @@ -194,13 +193,15 @@ def _fix_param_pid(args, config, checkpoint, inference_data): if hasattr(args, "param_ids") and args.param_ids is not None: if len(args.param_ids) == 2: - pids = list(range(int(args.param_ids[0]), int(args.param_ids[1])+1)) + pids = list(range(int(args.param_ids[0]), + int(args.param_ids[1])+1)) else: pids = args.param_ids else: pids = [args.param_id] - config = _fix_solve_parameters_with_pids(config, pids, db_meta_info, db_name) + config = _fix_solve_parameters_with_pids(config, pids, db_meta_info, + db_name) return config @@ -210,7 +211,8 @@ def _fix_solve_roi(config): return config -def _fix_solve_parameters_with_pids(config, pids, db_meta_info=None, db_name=None): +def _fix_solve_parameters_with_pids(config, pids, db_meta_info=None, + db_name=None): if db_name is None: db_name = checkOrCreateDB( config.general.db_host, @@ -241,7 +243,8 @@ def _fix_solve_parameters_with_pids(config, pids, db_meta_info=None, db_name=Non continue logger.info("getting params %s (id: %s) from database %s (sample: %s)", parameters, pid, db_name, - db_meta_info["sample"] if db_meta_info is not None else None) - solve_parameters = SolveParametersConfig(**parameters) # type: ignore + db_meta_info["sample"] if db_meta_info is not None + else None) + solve_parameters = SolveParametersConfig(**parameters) # type: ignore config.solve.parameters.append(solve_parameters) return config diff --git a/linajea/utils/parse_tracks_file.py b/linajea/utils/parse_tracks_file.py index a771db2..53f7265 100644 --- a/linajea/utils/parse_tracks_file.py +++ b/linajea/utils/parse_tracks_file.py @@ -102,11 +102,12 @@ def _parse_csv_fields(filename, scale=1.0, limit_to_roi=None): not limit_to_roi.contains(Coordinate(loc)): continue locations.append(loc) - track_info.append([int(row['cell_id']), - int(row['parent_id']), - int(row['track_id']), - [float(row['radius']) if 'radius' in row else None, - row.get("name")]]) + track_info.append( + [int(row['cell_id']), + int(row['parent_id']), + int(row['track_id']), + [float(row['radius']) if 'radius' in row else None, + row.get("name")]]) if 'div_state' in row: track_info[-1].append(int(row['div_state'])) diff --git a/linajea/utils/write_search_files.py b/linajea/utils/write_search_files.py index 053ac21..5a70a64 100644 --- a/linajea/utils/write_search_files.py +++ b/linajea/utils/write_search_files.py @@ -54,8 +54,8 @@ def write_search_configs(config, random_search, output_file, num_configs=None): itertools.product. If num_configs is set, shuffle list and take the num_configs first ones. """ - params = {k:v - for k,v in config.items() + params = {k: v + for k, v in config.items() if v is not None} params.pop('num_configs', None) @@ -73,8 +73,9 @@ def write_search_configs(config, random_search, output_file, num_configs=None): value = v[0] elif isinstance(v[0], str) or len(v) > 2: value = random.choice(v) - elif len(v) == 2 and isinstance(v[0], list) and isinstance(v[1], list) and \ - isinstance(v[0][0], str) and isinstance(v[1][0], str): + elif (len(v) == 2 and isinstance(v[0], list) and + isinstance(v[1], list) and + isinstance(v[0][0], str) and isinstance(v[1][0], str)): subset = random.choice(v) value = random.choice(subset) else: @@ -106,7 +107,6 @@ def write_search_configs(config, random_search, output_file, num_configs=None): toml.dump(search_configs, f) - if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument('--config', type=str, required=True, diff --git a/tests/test_greedy_baseline.py b/tests/test_greedy_baseline.py index 1b2bcd3..8757ca7 100644 --- a/tests/test_greedy_baseline.py +++ b/tests/test_greedy_baseline.py @@ -120,7 +120,7 @@ def test_greedy_split(self): ] self.assertCountEqual(selected_edges, expected_result) self.delete_db(db_name, db_host) - + def test_greedy_node_threshold(self): # x # 3| /-4 \ From 5f3373e58c161956439c4b40a76064b161c2b6a9 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 19 Jul 2022 16:49:38 -0400 Subject: [PATCH 210/263] incorporate refactoring feedback --- linajea/config/solve.py | 2 +- linajea/process_blockwise/solve_blockwise.py | 76 +++- linajea/tracking/__init__.py | 6 +- linajea/tracking/constraints.py | 332 +++++++++++------- linajea/tracking/cost_functions.py | 4 +- linajea/tracking/greedy_track.py | 2 +- linajea/tracking/solver.py | 347 +++++++++++-------- linajea/tracking/track.py | 184 +++------- 8 files changed, 559 insertions(+), 394 deletions(-) diff --git a/linajea/config/solve.py b/linajea/config/solve.py index 5b33ebc..127dbd9 100644 --- a/linajea/config/solve.py +++ b/linajea/config/solve.py @@ -282,7 +282,7 @@ class SolveConfig: greedy = attr.ib(type=bool, default=False) write_struct_svm = attr.ib(type=str, default=None) check_node_close_to_roi = attr.ib(type=bool, default=True) - timeout = attr.ib(type=int, default=120) + timeout = attr.ib(type=int, default=0) clip_low_score = attr.ib(type=float, default=None) grid_search = attr.ib(type=bool, default=False) random_search = attr.ib(type=bool, default=False) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index 901c8ce..cdf7a60 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -2,9 +2,12 @@ and a set of object and edge candidates. """ import logging +import os import time import daisy +import pylp + from linajea.utils import CandidateDatabase from .daisy_check_functions import ( check_function, write_done, @@ -228,8 +231,14 @@ def solve_in_block(linajea_config, greedy_track(graph=graph, selected_key=selected_keys[0], node_threshold=0.2) else: - track(graph, linajea_config, selected_keys, - block_id=block.block_id[1]) + solver = track(graph, linajea_config, selected_keys, + return_solver=linajea_config.solve.write_struct_svm) + + if linajea_config.solve.write_struct_svm: + write_struct_svm(solver, block.block_id[1], + linajea_config.solve.write_struct_svm) + logger.info("wrote struct svm data, skipping solving") + return 0 start_time = time.time() graph.update_edge_attrs( @@ -241,3 +250,66 @@ def solve_in_block(linajea_config, time.time() - start_time) write_done(block, step_name, db_name, db_host) return 0 + + +def write_struct_svm(solver, block_id, output_dir): + os.makedirs(output_dir, exist_ok=True) + block_id = str(block_id) + + # write all indicators as "object_id, indicator_id" + for k, objs in solver.indicators.items(): + with open(os.path.join(output_dir, k + "_b" + block_id), 'w') as f: + for obj, v in objs.items(): + f.write(f"{obj} {v}\n") + + # write features for objective + indicators = {} + num_features = {} + for k, fn in solver.node_indicator_fn_map.items(): + for n_id, node in solver.graph.nodes(data=True): + ind = solver.indicators[k][n_id] + costs = fn(node) + indicators[ind] = (k, costs) + num_features[k] = len(costs) + for k, fn in solver.edge_indicator_fn_map.items(): + for u, v, edge in solver.graph.edges(data=True): + ind = solver.indicators[k][(u, v)] + costs = fn(edge) + indicators[ind] = (k, costs) + num_features[k] = len(costs) + + features_locs = {} + acc = 0 + for k, v in num_features.items(): + features_locs[k] = acc + acc += v + num_features = acc + assert sorted(indicators.keys()) == list(range(len(indicators))), \ + "some error reading indicators and features" + with open(os.path.join(output_dir, "features_b" + block_id), 'w') as f: + for ind in sorted(indicators.keys()): + k, costs = indicators[ind] + features = [0]*num_features + features_loc = features_locs[k] + features[features_loc:features_loc+len(costs)] = costs + f.write(" ".join([str(f) for f in features]) + "\n") + + # write constraints + def rel_to_str(rel): + if rel == pylp.Relation.Equal: + return " == " + elif rel == pylp.Relation.LessEqual: + return " <= " + elif rel == pylp.Relation.GreaterEqual: + return " >= " + else: + raise RuntimeError("invalid pylp.Relation: %s", rel) + + with open(os.path.join(output_dir, "constraints_b" + block_id), 'w') as f: + for constraint in solver.main_constraints: + val = constraint.get_value() + rel = rel_to_str(constraint.get_relation()) + coeffs = " ".join( + [f"{v}*{idx}" + for idx, v in constraint.get_coefficients().items()]) + f.write(f"{coeffs} {rel} {val}\n") diff --git a/linajea/tracking/__init__.py b/linajea/tracking/__init__.py index 3f1b078..191282e 100644 --- a/linajea/tracking/__init__.py +++ b/linajea/tracking/__init__.py @@ -1,7 +1,7 @@ # flake8: noqa -from .track import (get_edge_indicator_fn_map_default, - get_node_indicator_fn_map_default, +from .track import (get_default_edge_indicator_costs, + get_default_node_indicator_costs, track) from .greedy_track import greedy_track from .track_graph import TrackGraph -from .solver import Solver, BasicSolver, CellStateSolver +from .solver import Solver diff --git a/linajea/tracking/constraints.py b/linajea/tracking/constraints.py index 5d4a36b..fcca74e 100644 --- a/linajea/tracking/constraints.py +++ b/linajea/tracking/constraints.py @@ -1,115 +1,169 @@ import pylp -def ensure_edge_endpoints(edge, indicators): - """if e is selected, u and v have to be selected +def ensure_edge_endpoints(graph, indicators): + """If edge is selected, u and v have to be selected. + + Constraint: + 2 * edge(u, v) - u - v <= 0 + + Args + ---- + graph: TrackGraph + Graph containing the node and edge candidates, the ILP will be solved + for this graph. + indicators: dict str: dict (int or pair of int): int + Contains a dict for every indicator type (str). + Each dict maps from node (int) or edge (pair of int) candidate to + the corresponding indicator variable/index (int). Each candidate can + have indicators of different types associated to it. """ - u, v = edge - ind_e = indicators["edge_selected"][edge] - ind_u = indicators["node_selected"][u] - ind_v = indicators["node_selected"][v] + constraints = [] + for edge in graph.edges(): + u, v = edge + ind_e = indicators["edge_selected"][edge] + ind_u = indicators["node_selected"][u] + ind_v = indicators["node_selected"][v] - constraint = pylp.LinearConstraint() - constraint.set_coefficient(ind_e, 2) - constraint.set_coefficient(ind_u, -1) - constraint.set_coefficient(ind_v, -1) - constraint.set_relation(pylp.Relation.LessEqual) - constraint.set_value(0) + constraint = pylp.LinearConstraint() + constraint.set_coefficient(ind_e, 2) + constraint.set_coefficient(ind_u, -1) + constraint.set_coefficient(ind_v, -1) + constraint.set_relation(pylp.Relation.LessEqual) + constraint.set_value(0) + constraints.append(constraint) - return [constraint] + return constraints -def ensure_one_predecessor(node, indicators, graph, **kwargs): +def ensure_one_predecessor(graph, indicators): """Every selected node has exactly one selected edge to the previous frame This includes the special "appear" edge. + Constraint: sum(prev) - node = 0 # exactly one prev edge, iff node selected + + Args + ---- + graph: TrackGraph + Graph containing the node and edge candidates, the ILP will be solved + for this graph. + indicators: dict str: dict (int or pair of int): int + Contains a dict for every indicator type (str). + Each dict maps from node (int) or edge (pair of int) candidate to + the corresponding indicator variable/index (int). Each candidate can + have indicators of different types associated to it. """ - pinned_edges = kwargs["pinned_edges"] + constraints = [] + for node in graph.nodes(): + constraint = pylp.LinearConstraint() - constraint_prev = pylp.LinearConstraint() + # all neighbors in previous frame + for edge in graph.prev_edges(node): + constraint.set_coefficient(indicators["edge_selected"][edge], 1) - # all neighbors in previous frame - pinned_to_1 = [] - for edge in graph.prev_edges(node): - constraint_prev.set_coefficient(indicators["edge_selected"][edge], 1) - if edge in pinned_edges and pinned_edges[edge]: - pinned_to_1.append(edge) - if len(pinned_to_1) > 1: - raise RuntimeError( - "Node %d has more than one prev edge pinned: %s" - % (node, pinned_to_1)) - # plus "appear" - constraint_prev.set_coefficient(indicators["node_appear"][node], 1) + # plus "appear" + constraint.set_coefficient(indicators["node_appear"][node], 1) - # node - constraint_prev.set_coefficient(indicators["node_selected"][node], -1) + # node + constraint.set_coefficient(indicators["node_selected"][node], -1) - # relation, value - constraint_prev.set_relation(pylp.Relation.Equal) + # relation, value + constraint.set_relation(pylp.Relation.Equal) - constraint_prev.set_value(0) + constraint.set_value(0) + constraints.append(constraint) - return [constraint_prev] + return constraints -def ensure_at_most_two_successors(node, indicators, graph, **kwargs): +def ensure_at_most_two_successors(graph, indicators): """Every selected node has zero to two selected edges to the next frame. + Constraint: sum(next) - 2*node <= 0 # at most two next edges + + Args + ---- + graph: TrackGraph + Graph containing the node and edge candidates, the ILP will be solved + for this graph. + indicators: dict str: dict (int or pair of int): int + Contains a dict for every indicator type (str). + Each dict maps from node (int) or edge (pair of int) candidate to + the corresponding indicator variable/index (int). Each candidate can + have indicators of different types associated to it. """ - constraint_next = pylp.LinearConstraint() + constraints = [] + for node in graph.nodes(): + constraint = pylp.LinearConstraint() - for edge in graph.next_edges(node): - constraint_next.set_coefficient(indicators["edge_selected"][edge], 1) + for edge in graph.next_edges(node): + constraint.set_coefficient(indicators["edge_selected"][edge], 1) - # node - constraint_next.set_coefficient(indicators["node_selected"][node], -2) + # node + constraint.set_coefficient(indicators["node_selected"][node], -2) - # relation, value - constraint_next.set_relation(pylp.Relation.LessEqual) + # relation, value + constraint.set_relation(pylp.Relation.LessEqual) - constraint_next.set_value(0) + constraint.set_value(0) + constraints.append(constraint) - return [constraint_next] + return constraints -def ensure_split_set_for_divs(node, indicators, graph, **kwargs): +def ensure_split_set_for_divs(graph, indicators): """Ensure that the split indicator is set for every cell that splits into two daughter cells. I.e., each node with two forwards edges is a split node. - Constraint 1 + Constraint 1: sum(forward edges) - split <= 1 sum(forward edges) > 1 => split == 1 - Constraint 2 + Constraint 2: sum(forward edges) - 2*split >= 0 sum(forward edges) <= 1 => split == 0 + + Args + ---- + graph: TrackGraph + Graph containing the node and edge candidates, the ILP will be solved + for this graph. + indicators: dict str: dict (int or pair of int): int + Contains a dict for every indicator type (str). + Each dict maps from node (int) or edge (pair of int) candidate to + the corresponding indicator variable/index (int). Each candidate can + have indicators of different types associated to it. """ - constraint_1 = pylp.LinearConstraint() - constraint_2 = pylp.LinearConstraint() + constraints = [] + for node in graph.nodes(): + constraint_1 = pylp.LinearConstraint() + constraint_2 = pylp.LinearConstraint() - # sum(forward edges) - for edge in graph.next_edges(node): - constraint_1.set_coefficient(indicators["edge_selected"][edge], 1) - constraint_2.set_coefficient(indicators["edge_selected"][edge], 1) + # sum(forward edges) + for edge in graph.next_edges(node): + constraint_1.set_coefficient(indicators["edge_selected"][edge], 1) + constraint_2.set_coefficient(indicators["edge_selected"][edge], 1) - # -[2*]split - constraint_1.set_coefficient(indicators["node_split"][node], -1) - constraint_2.set_coefficient(indicators["node_split"][node], -2) + # -[2*]split + constraint_1.set_coefficient(indicators["node_split"][node], -1) + constraint_2.set_coefficient(indicators["node_split"][node], -2) - constraint_1.set_relation(pylp.Relation.LessEqual) - constraint_2.set_relation(pylp.Relation.GreaterEqual) + constraint_1.set_relation(pylp.Relation.LessEqual) + constraint_2.set_relation(pylp.Relation.GreaterEqual) - constraint_1.set_value(1) - constraint_2.set_value(0) + constraint_1.set_value(1) + constraint_2.set_value(0) + constraints.append(constraint_1) + constraints.append(constraint_2) - return [constraint_1, constraint_2] + return constraints -def ensure_pinned_edge(edge, indicators, selected): +def ensure_pinned_edge(graph, indicators, selected_key): """Ensure that if an edge has already been set by a neighboring block its state stays consistent (pin/fix its state). @@ -118,88 +172,132 @@ def ensure_pinned_edge(edge, indicators, selected): selected(e) = 1 else: selected(e) = 0 + + Args + ---- + graph: TrackGraph + Graph containing the node and edge candidates, the ILP will be solved + for this graph. + indicators: dict str: dict (int or pair of int): int + Contains a dict for every indicator type (str). + Each dict maps from node (int) or edge (pair of int) candidate to + the corresponding indicator variable/index (int). Each candidate can + have indicators of different types associated to it. + selected_key: str + Consider this property to determine pinned state of candidate. """ - ind_e = indicators["edge_selected"][edge] - constraint = pylp.LinearConstraint() - constraint.set_coefficient(ind_e, 1) - constraint.set_relation(pylp.Relation.Equal) - constraint.set_value(1 if selected else 0) + constraints = [] + for edge in graph.edges(): + if selected_key in graph.edges[edge]: + selected = graph.edges[edge][selected_key] - return [constraint] + ind_e = indicators["edge_selected"][edge] + constraint = pylp.LinearConstraint() + constraint.set_coefficient(ind_e, 1) + constraint.set_relation(pylp.Relation.Equal) + constraint.set_value(1 if selected else 0) + constraints.append(constraint) + return constraints -def ensure_one_state(node, indicators): + +def ensure_one_state(graph, indicators): """Ensure that each selected node has exactly on state assigned to it Constraint: split(n) + child(n) + continuation(n) = selected(n) + + Args + ---- + graph: TrackGraph + Graph containing the node and edge candidates, the ILP will be solved + for this graph. + indicators: dict str: dict (int or pair of int): int + Contains a dict for every indicator type (str). + Each dict maps from node (int) or edge (pair of int) candidate to + the corresponding indicator variable/index (int). Each candidate can + have indicators of different types associated to it. """ - constraint = pylp.LinearConstraint() - constraint.set_coefficient(indicators["node_split"][node], 1) - constraint.set_coefficient(indicators["node_child"][node], 1) - constraint.set_coefficient(indicators["node_continuation"][node], 1) - constraint.set_coefficient(indicators["node_selected"][node], -1) - constraint.set_relation(pylp.Relation.Equal) - constraint.set_value(0) + constraints = [] + for node in graph.nodes(): + constraint = pylp.LinearConstraint() + constraint.set_coefficient(indicators["node_split"][node], 1) + constraint.set_coefficient(indicators["node_child"][node], 1) + constraint.set_coefficient(indicators["node_continuation"][node], 1) + constraint.set_coefficient(indicators["node_selected"][node], -1) + constraint.set_relation(pylp.Relation.Equal) + constraint.set_value(0) + constraints.append(constraint) - return [constraint] + return constraints -def ensure_split_child(edge, indicators): +def ensure_split_child(graph, indicators): """If an edge is selected, the split (division) and child indicators are linked. Let e=(u,v) be an edge linking node u at time t + 1 to v in time t. - Constraints: + Constraint 1: child(u) + selected(e) - split(v) <= 1 + Constraint 2: split(v) + selected(e) - child(u) <= 1 - """ - u, v = edge - ind_e = indicators["edge_selected"][edge] - split_v = indicators["node_split"][v] - child_u = indicators["node_child"][u] - - constraint_1 = pylp.LinearConstraint() - constraint_1.set_coefficient(child_u, 1) - constraint_1.set_coefficient(ind_e, 1) - constraint_1.set_coefficient(split_v, -1) - constraint_1.set_relation(pylp.Relation.LessEqual) - constraint_1.set_value(1) - constraint_2 = pylp.LinearConstraint() - constraint_2.set_coefficient(split_v, 1) - constraint_2.set_coefficient(ind_e, 1) - constraint_2.set_coefficient(child_u, -1) - constraint_2.set_relation(pylp.Relation.LessEqual) - constraint_2.set_value(1) - - return [constraint_1, constraint_2] - - -def get_constraints_default(config): + Args + ---- + graph: TrackGraph + Graph containing the node and edge candidates, the ILP will be solved + for this graph. + indicators: dict str: dict (int or pair of int): int + Contains a dict for every indicator type (str). + Each dict maps from node (int) or edge (pair of int) candidate to + the corresponding indicator variable/index (int). Each candidate can + have indicators of different types associated to it. + """ + constraints = [] + for edge in graph.edges(): + u, v = edge + ind_e = indicators["edge_selected"][edge] + split_v = indicators["node_split"][v] + child_u = indicators["node_child"][u] + + constraint_1 = pylp.LinearConstraint() + constraint_1.set_coefficient(child_u, 1) + constraint_1.set_coefficient(ind_e, 1) + constraint_1.set_coefficient(split_v, -1) + constraint_1.set_relation(pylp.Relation.LessEqual) + constraint_1.set_value(1) + + constraint_2 = pylp.LinearConstraint() + constraint_2.set_coefficient(split_v, 1) + constraint_2.set_coefficient(ind_e, 1) + constraint_2.set_coefficient(child_u, -1) + constraint_2.set_relation(pylp.Relation.LessEqual) + constraint_2.set_value(1) + constraints.append(constraint_1) + constraints.append(constraint_2) + + return constraints + + +def get_default_constraints(config): solver_type = config.solve.solver_type if solver_type == "basic": pin_constraints_fn_list = [ensure_pinned_edge] - edge_constraints_fn_list = [ensure_edge_endpoints] - node_constraints_fn_list = [] - inter_frame_constraints_fn_list = [ - ensure_one_predecessor, - ensure_at_most_two_successors, - ensure_split_set_for_divs] + constraints_fn_list = [ensure_edge_endpoints, + ensure_one_predecessor, + ensure_at_most_two_successors, + ensure_split_set_for_divs] elif solver_type == "cell_state": pin_constraints_fn_list = [ensure_pinned_edge] - edge_constraints_fn_list = [ensure_edge_endpoints, ensure_split_child] - node_constraints_fn_list = [ensure_one_state] - inter_frame_constraints_fn_list = [ - ensure_one_predecessor, - ensure_at_most_two_successors, - ensure_split_set_for_divs] + constraints_fn_list = [ensure_edge_endpoints, ensure_split_child, + ensure_one_state, + ensure_one_predecessor, + ensure_at_most_two_successors, + ensure_split_set_for_divs] else: raise RuntimeError("solver_type %s unknown for constraints", solver_type) return (pin_constraints_fn_list, - edge_constraints_fn_list, - node_constraints_fn_list, - inter_frame_constraints_fn_list) + constraints_fn_list) diff --git a/linajea/tracking/cost_functions.py b/linajea/tracking/cost_functions.py index 7f05ac9..0ecac4a 100644 --- a/linajea/tracking/cost_functions.py +++ b/linajea/tracking/cost_functions.py @@ -82,7 +82,7 @@ def is_close(obj): return is_close -def get_node_indicator_fn_map_default(config, parameters, graph): +def get_default_node_indicator_costs(config, parameters, graph): if parameters.feature_func == "noop": feature_func = lambda x: x # noqa: E731 elif parameters.feature_func == "log": @@ -128,7 +128,7 @@ def get_node_indicator_fn_map_default(config, parameters, graph): return fn_map -def get_edge_indicator_fn_map_default(config, parameters): +def get_default_edge_indicator_costs(config, parameters): if parameters.feature_func == "noop": feature_func = lambda x: x # noqa: E731 elif parameters.feature_func == "log": diff --git a/linajea/tracking/greedy_track.py b/linajea/tracking/greedy_track.py index f9f96d6..4f9eef6 100644 --- a/linajea/tracking/greedy_track.py +++ b/linajea/tracking/greedy_track.py @@ -56,7 +56,7 @@ def greedy_track( Args ---- - graph: nx.DiGraph + graph: daisy.SharedSubgraph Compute tracks from this graph, if None load graph from db db_name: str If graph not provided, load graph from this db diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index 9baa25f..156dd04 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -1,70 +1,208 @@ """Provides a solver object encapsulating the ILP solver """ -# -*- coding: utf-8 -*- import logging import pylp -from .constraints import (ensure_at_most_two_successors, - ensure_edge_endpoints, - ensure_one_predecessor, - ensure_pinned_edge, - ensure_split_set_for_divs, - ensure_one_state, - ensure_split_child) - logger = logging.getLogger(__name__) class Solver(object): - ''' - Class for initializing and solving the ILP problem for - creating tracks from candidate nodes and edges using pylp. - ''' - def __init__(self, track_graph, - node_indicator_keys, edge_indicator_keys, - pin_constraints_fn_list, edge_constraints_fn_list, - node_constraints_fn_list, inter_frame_constraints_fn_list, - timeout=120): - - self.graph = track_graph + """ Class for initializing and solving an ILP problem for candidate nodes + and edges using pylp. + + The set of indicators and the main constraints have to be set on + initialization. The resulting problem can be solved multiple times + (using the same pylp.Solver instance) for different objectives by + calling `update_objective` in between successive calls to solve_and_set. + In each call to `update_objective` different costs per indicator can be + used and the value for a different set of indicators can be pinned. + + Attributes + ---------- + graph: nx.DiGraph or TrackGraph + Graph containing the node and edge candidates, the ILP will be solved + for this graph. + timeout: int + Terminate solver after this time; if terminated early solution might + be suboptimal; set to 0 to disable (default). + num_threads: int + Number of threads to use for solving. Default is 1. Depending on + the used solver and the type of problem parallelization of ILPs + often does not scale that well, one approach is to use different + methods or heuristics in parallel and stop as soon as one of them + was successful. + num_vars: int + Will be set to the total number of indicator variables. + selected_key: str + After solving, this property of node/edge candidates will be set + depending on if they are part of the solution or not. Its value + will be set by calling `update_objective`. + objective: pylp.LinearObjective + Will be set to the objective to be solved in `update_objective` + main_constraints: list of pylp.LinearConstraint + Will be filled by calling the Callables in constraints_fns on the + graph + pin_constraints: list of pylp.LinearConstraint + Will be filled by calling the Callables in pin_constraints_fns on the + graph + solver: pylp.LinearSolver + Will contain an instance of a pylp solver (e.g. Gurobi) + node_indicator_names: list of str + List of types of node indicators + edge_indicator_names: list of str + List of types of edge indicators + constraints_fns: list of Callable + Each Callable should handle a single type of constraint. + It should create the respective constraints for all affected objects in + the graph and return them. + Add more Callable to this list to add additional constraints. + See tracking/constraints.py for examples. + Interface: fn(graph, indicators) -> constraints + graph: Create constraints for nodes/edges in this graph + indicators: The indicator dict created by this Solver object + constraints: list of pylp.LinearConstraint + pin_constraints_fns: list of Callable + Each Callable should handle a single type of pin constraint. + Use this to add constraints to pin indicators to specific states. + Created only if indicator has already been set by neighboring blocks. + Interface: fn(graph, indicators, selected) -> constraints + graph: Create constraints for nodes/edges in this graph + indicators: The indicator dict created by this Solver object + selected_key: Consider this property to determine state of candidate + constraints: list of pylp.LinearConstraint + """ + def __init__(self, graph, + node_indicator_names, edge_indicator_names, + constraints_fns, pin_constraints_fns, timeout=0, + num_threads=1): + """Constructs a Solver object. + Sets the object attributes and calls `_create_indicators` to create + all indicators, `_create_solver` to create an instance of a pylp + solver object and `_create_constraints` to create the constraints. + Call `update_objective` afterwards to set the objective, followed by + `solve_and_set` to compute the solution and to set the appropriate + attribute for the selected node and edge candidates. + + Args + ---- + graph: nx.DiGraph or TrackGraph + Graph containing the node and edge candidates, the ILP will be + solved for this graph. + node_indicator_names: list of str + List of types of node indicators + edge_indicator_names: list of str + List of types of edge indicators + constraints_fns: list of Callable + Each Callable should handle a single type of constraint. + It should create the respective constraints for all affected + objects in the graph and return them. + Add more Callable to this list to add additional constraints. + See tracking/constraints.py for examples. + Interface: fn(graph, indicators) -> constraints + graph: Create constraints for nodes/edges in this graph + indicators: The indicator dict created by this Solver object + constraints: list of pylp.LinearConstraint + pin_constraints_fns: list of Callable + Each Callable should handle a single type of pin constraint. + Use this to add constraints to pin indicators to specific states. + Created only if indicator has already been set by neighboring + blocks. + Interface: fn(graph, indicators, selected) -> constraints + graph: Create constraints for nodes/edges in this graph + indicators: The indicator dict created by this Solver object + selected_key: Consider this property to determine state of + candidate + constraints: list of pylp.LinearConstraint + timeout: int + Terminate solver after this time; if terminated early solution + might be suboptimal; set to 0 to disable (default). + num_threads: int + Number of threads to use for solving. Default is 1. Depending on + the used solver and the type of problem parallelization of ILPs + often does not scale that well, one approach is to use different + methods or heuristics in parallel and stop as soon as one of them + was successful. + """ + + self.graph = graph self.timeout = timeout - - self.pinned_edges = {} + self.num_threads = num_threads self.num_vars = None + self.selected_key = None self.objective = None self.main_constraints = [] # list of LinearConstraint objects self.pin_constraints = [] # list of LinearConstraint objects self.solver = None - self.node_indicator_keys = set(node_indicator_keys) - self.edge_indicator_keys = set(edge_indicator_keys) + self.node_indicator_names = set(node_indicator_names) + self.edge_indicator_names = set(edge_indicator_names) - self.pin_constraints_fn_list = pin_constraints_fn_list - self.edge_constraints_fn_list = edge_constraints_fn_list - self.node_constraints_fn_list = node_constraints_fn_list - self.inter_frame_constraints_fn_list = inter_frame_constraints_fn_list + self.constraints_fns = constraints_fns + self.pin_constraints_fns = pin_constraints_fns self._create_indicators() self._create_solver() self._create_constraints() - def update_objective(self, node_indicator_fn_map, edge_indicator_fn_map, + def update_objective(self, node_indicator_costs, edge_indicator_costs, selected_key): + """Set/Update the objective using a new set of node and edge costs. + + Notes + ----- + Has to be called before solving. + + Args + ---- + node_indicator_costs: dict str: Callable + Map from (node) indicator type to Callable. The Callable will be + executed for every indicator of the respective type. It returns + a list of costs for that indicator. The sum of costs will be + added as a coefficient for that indicator to the objective. + See tracking/cost_functions.py for examples. + Interface: fn(obj: dict[str, Number]) -> cost: list[Number] + fn: + Callable that takes a dict and returns a list of Numbers + (typically a (parameterized) closure). + obj: + The data associated with a node or edge + cost: + The computed cost that will be added to the objective for + the respective indicator + edge_indicator_costs: dict str: Callable + Map from (edge) indicator type to Callable. The Callable will be + executed for every indicator of the respective type. It returns + a list of costs for that indicator. The sum of costs will be + added as a coefficient for that indicator to the objective. + See tracking/cost_functions.py for examples. + Interface: fn(obj: dict[str, Number]) -> cost: list[Number] + fn: + Callable that takes a dict and returns a list of Numbers + (typically a (parameterized) closure). + obj: + The data associated with a node or edge + cost: + The computed cost that will be added to the objective for + the respective indicator + selected_key: str + After solving, this property of node/edge candidates will be set + depending on if they are part of the solution or not. In addition + it will be passed to the pin_constraints_fns Callables. + """ assert ( - set(node_indicator_fn_map.keys()) == self.node_indicator_keys and - set(edge_indicator_fn_map.keys()) == self.edge_indicator_keys), \ + set(node_indicator_costs.keys()) == self.node_indicator_names and + set(edge_indicator_costs.keys()) == self.edge_indicator_names), \ "cannot change set of indicators during one run!" - self.node_indicator_fn_map = node_indicator_fn_map - self.edge_indicator_fn_map = edge_indicator_fn_map + self.node_indicator_costs = node_indicator_costs + self.edge_indicator_costs = edge_indicator_costs self.selected_key = selected_key - self._set_objective() + self._create_objective() self.solver.set_objective(self.objective) - self.pinned_edges = {} self.pin_constraints = [] self._add_pin_constraints() all_constraints = pylp.LinearConstraints() @@ -77,10 +215,20 @@ def _create_solver(self): self.num_vars, pylp.VariableType.Binary, preference=pylp.Preference.Any) - self.solver.set_num_threads(1) + self.solver.set_num_threads(self.num_threads) self.solver.set_timeout(self.timeout) def solve(self): + """Solves the ILP + + Notes + ----- + Called internally by `solve_and_set`, if access to the whole + indicator solution vector is required, call this function directly. + """ + assert self.objective is not None, ( + "objective has to be defined before solving by calling" + "update_objective") solution, message = self.solver.solve() logger.info(message) logger.info("costs of solution: %f", solution.get_value()) @@ -89,6 +237,16 @@ def solve(self): def solve_and_set(self, node_key="node_selected", edge_key="edge_selected"): + """Solves the ILP and sets the selected_key property of + node and edge candidates according to the solution + + Args + ---- + node_key: str + For node candidates, check solution state of this indicator. + edge_key: str + For edge candidates, check solution state of this indicator. + """ solution = self.solve() for v in self.graph.nodes: @@ -104,32 +262,32 @@ def _create_indicators(self): self.indicators = {} self.num_vars = 0 - for k in self.node_indicator_keys: + for k in self.node_indicator_names: self.indicators[k] = {} for node in self.graph.nodes: self.indicators[k][node] = self.num_vars self.num_vars += 1 - for k in self.edge_indicator_keys: + for k in self.edge_indicator_names: self.indicators[k] = {} for edge in self.graph.edges(): self.indicators[k][edge] = self.num_vars self.num_vars += 1 - def _set_objective(self): + def _create_objective(self): logger.debug("setting objective") objective = pylp.LinearObjective(self.num_vars) # node costs - for k, fn in self.node_indicator_fn_map.items(): + for k, fn in self.node_indicator_costs.items(): for n_id, node in self.graph.nodes(data=True): objective.set_coefficient(self.indicators[k][n_id], sum(fn(node))) # edge costs - for k, fn in self.edge_indicator_fn_map.items(): + for k, fn in self.edge_indicator_costs.items(): for u, v, edge in self.graph.edges(data=True): objective.set_coefficient(self.indicators[k][(u, v)], sum(fn(edge))) @@ -140,101 +298,20 @@ def _create_constraints(self): self.main_constraints = [] - self._add_edge_constraints() - self._add_node_constraints() - - for t in range(self.graph.begin, self.graph.end): - self._add_inter_frame_constraints(t) + self._add_constraints() def _add_pin_constraints(self): logger.debug("setting pin constraints: %s", - self.pin_constraints_fn_list) - - for edge in self.graph.edges(): - if self.selected_key in self.graph.edges[edge]: - selected = self.graph.edges[edge][self.selected_key] - self.pinned_edges[edge] = selected - - for fn in self.pin_constraints_fn_list: - self.pin_constraints.extend( - fn(edge, self.indicators, selected)) - - def _add_edge_constraints(self): - - logger.debug("setting edge constraints: %s", - self.edge_constraints_fn_list) - - for edge in self.graph.edges(): - for fn in self.edge_constraints_fn_list: - self.main_constraints.extend(fn(edge, self.indicators)) - - def _add_node_constraints(self): - - logger.debug("setting node constraints: %s", - self.node_constraints_fn_list) - - for node in self.graph.nodes(): - for fn in self.node_constraints_fn_list: - self.main_constraints.extend(fn(node, self.indicators)) - - def _add_inter_frame_constraints(self, t): - '''Linking constraints from t to t+1.''' - - logger.debug("setting inter-frame constraints for frame %d: %s", t, - self.inter_frame_constraints_fn_list) - - for node in self.graph.cells_by_frame(t): - for fn in self.inter_frame_constraints_fn_list: - self.main_constraints.extend( - fn(node, self.indicators, self.graph, - pinned_edges=self.pinned_edges)) - - -class BasicSolver(Solver): - '''Specialized class initialized with the basic indicators and constraints - ''' - def __init__(self, track_graph, timeout=120): - - pin_constraints_fn_list = [ensure_pinned_edge] - edge_constraints_fn_list = [ensure_edge_endpoints] - node_constraints_fn_list = [] - inter_frame_constraints_fn_list = [ - ensure_one_predecessor, - ensure_at_most_two_successors, - ensure_split_set_for_divs] - - node_indicator_keys = ["node_selected", "node_appear", "node_split"] - edge_indicator_keys = ["edge_selected"] - - super(BasicSolver, self).__init__( - track_graph, node_indicator_keys, edge_indicator_keys, - pin_constraints_fn_list, edge_constraints_fn_list, - node_constraints_fn_list, inter_frame_constraints_fn_list, - timeout=timeout) - - -class CellStateSolver(Solver): - '''Specialized class initialized with the indicators and constraints - necessary to include cell state information in addition to the basic - indicators and constraints - ''' - def __init__(self, track_graph, timeout=120): - - pin_constraints_fn_list = [ensure_pinned_edge] - edge_constraints_fn_list = [ensure_edge_endpoints, ensure_split_child] - node_constraints_fn_list = [ensure_one_state] - inter_frame_constraints_fn_list = [ - ensure_one_predecessor, - ensure_at_most_two_successors, - ensure_split_set_for_divs] - - node_indicator_keys = ["node_selected", "node_appear", "node_split", - "node_child", "node_continuation"] - edge_indicator_keys = ["edge_selected"] - - super(CellStateSolver, self).__init__( - track_graph, node_indicator_keys, edge_indicator_keys, - pin_constraints_fn_list, edge_constraints_fn_list, - node_constraints_fn_list, inter_frame_constraints_fn_list, - timeout=timeout) + self.pin_constraints_fns) + + for fn in self.pin_constraints_fns: + self.pin_constraints.extend( + fn(self.graph, self.indicators, self.selected_key)) + + def _add_constraints(self): + + logger.debug("setting constraints: %s", self.constraints_fns) + + for fn in self.constraints_fns: + self.main_constraints.extend(fn(self.graph, self.indicators)) diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 35d69b4..97f23f9 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -2,15 +2,12 @@ parameter sets """ import logging -import os import time -import pylp - from .solver import Solver from .track_graph import TrackGraph -from .cost_functions import (get_edge_indicator_fn_map_default, - get_node_indicator_fn_map_default) +from .cost_functions import (get_default_edge_indicator_costs, + get_default_node_indicator_costs) from linajea.tracking import constraints @@ -18,10 +15,9 @@ def track(graph, config, selected_key, frame_key='t', - node_indicator_fn_map=None, edge_indicator_fn_map=None, - pin_constraints_fn_list=[], edge_constraints_fn_list=[], - node_constraints_fn_list=[], inter_frame_constraints_fn_list=[], - block_id=0): + node_indicator_costs=None, edge_indicator_costs=None, + constraints_fns=[], pin_constraints_fns=[], + return_solver=False): ''' A wrapper function that takes a daisy subgraph and input parameters, creates and solves the ILP to create tracks, and updates the daisy subgraph to reflect the selected nodes and edges. @@ -49,63 +45,52 @@ def track(graph, config, selected_key, frame_key='t', The name of the node attribute that corresponds to the frame of the node. Defaults to "t". - node_indicator_fn_map (Callable): + node_indicator_costs (Callable): Callable that returns a dict of str: Callable. + Will be called once per set of parameters. One entry per type of node indicator the solver should have; The Callable stored in the value of each entry will be called on each node and should return the cost for this indicator in the objective. - edge_indicator_fn_map (Callable): + edge_indicator_costs (Callable): Callable that returns a dict of str: Callable. + Will be called once per set of parameters. One entry per type of edge indicator the solver should have; The Callable stored in the value of each entry will be called on each edge and should return the cost for this indicator in the objective. - pin_constraints_fn_list (list of Callable) - - List of Callable that return a list of pylp.LinearConstraint each. - Use this to add constraints to pin edge indicators to specific - states. Called only if edge has already been set by neighboring - blocks. - Interface: fn(edge, indicators, selected) - edge: Create constraints for this edge - indicators: The indicator map created by the Solver object - selected: Will be set to the selected state of this edge - - edge_constraints_fn_list (list of Callable) - - List of Callable that return a list of pylp.LinearConstraint each. - Use this to add constraints on a specific edge. - Interface: fn(edge, indicators) - edge: Create constraints for this edge - indicators: The indicator map created by the Solver object - - node_constraints_fn_list (list of Callable) + constraints_fns (list of Callable) - List of Callable that return a list of pylp.LinearConstraint each. - Use this to add constraints on a specific node. - Interface: fn(node, indicators) - node: Create constraints for this node - indicators: The indicator map created by the Solver object + Each Callable should handle a single type of constraint. + It should create the respective constraints for all affected + objects in the graph and return them. + Add more Callable to this list to add additional constraints. + See tracking/constraints.py for examples. + Interface: fn(graph, indicators) -> constraints + graph: Create constraints for nodes/edges in this graph + indicators: The indicator dict created by this Solver object + constraints: list of pylp.LinearConstraint - inter_frame_constraints_fn_list (list of Callable) + pin_constraints_fns (list of Callable) - List of Callable that return a list of pylp.LinearConstraint each. - d - Interface: fn(node, indicators, graph, **kwargs) - node: Create constraints for this node - indicators: The indicator map created by the Solver object - graph: The track graph that is solved. - **kwwargs: Additional parameters, so far only contains - `pinned_edges`, requires changes to Solver object to extend. + Each Callable should handle a single type of pin constraint. + Use this to add constraints to pin indicators to specific states. + Created only if indicator has already been set by neighboring + blocks. + Interface: fn(graph, indicators, selected) -> constraints + graph: Create constraints for nodes/edges in this graph + indicators: The indicator dict created by this Solver object + selected_key: Consider this property to determine state of + candidate + constraints: list of pylp.LinearConstraint - block_id (``int``, optional): + return_solver (boolean) - The ID of the current daisy block if data is processed block-wise. + If True the solver object is returned instead of solving directly. ''' # assuming graph is a daisy subgraph if graph.number_of_nodes() == 0: @@ -130,42 +115,38 @@ def track(graph, config, selected_key, frame_key='t', total_solve_time = 0 if config.solve.solver_type is not None: - constrs = constraints.get_constraints_default(config) - pin_constraints_fn_list = constrs[0] - edge_constraints_fn_list = constrs[1] - node_constraints_fn_list = constrs[2] - inter_frame_constraints_fn_list = constrs[3] + constrs = constraints.get_default_constraints(config) + pin_constraints_fns = constrs[0] + constraints_fns = constrs[1] for parameters, key in zip(parameters_sets, selected_key): - if node_indicator_fn_map is None: - _node_indicator_fn_map = get_node_indicator_fn_map_default( + if node_indicator_costs is None: + _node_indicator_costs = get_default_node_indicator_costs( config, parameters, track_graph) else: - _node_indicator_fn_map = node_indicator_fn_map(config, parameters, - track_graph) - if edge_indicator_fn_map is None: - _edge_indicator_fn_map = get_edge_indicator_fn_map_default( + _node_indicator_costs = node_indicator_costs(config, parameters, + track_graph) + if edge_indicator_costs is None: + _edge_indicator_costs = get_default_edge_indicator_costs( config, parameters) else: - _edge_indicator_fn_map = edge_indicator_fn_map(config, parameters, - track_graph) + _edge_indicator_costs = edge_indicator_costs(config, parameters, + track_graph) if not solver: solver = Solver( track_graph, - list(_node_indicator_fn_map.keys()), - list(_edge_indicator_fn_map.keys()), - pin_constraints_fn_list, edge_constraints_fn_list, - node_constraints_fn_list, inter_frame_constraints_fn_list, + list(_node_indicator_costs.keys()), + list(_edge_indicator_costs.keys()), + constraints_fns, pin_constraints_fns, timeout=config.solve.timeout) - solver.update_objective(_node_indicator_fn_map, - _edge_indicator_fn_map, key) + solver.update_objective(_node_indicator_costs, + _edge_indicator_costs, key) + + if return_solver: + return solver - if config.solve.write_struct_svm: - write_struct_svm(solver, block_id, config.solve.write_struct_svm) - logger.info("wrote struct svm data, skipping solving") - break logger.info("Solving for key %s", str(key)) start_time = time.time() solver.solve_and_set() @@ -178,66 +159,3 @@ def track(graph, config, selected_key, frame_key='t', data[key] = track_graph.edges[(u, v)][key] logger.info("Solving ILP for all parameters took %s seconds", str(total_solve_time)) - - -def write_struct_svm(solver, block_id, output_dir): - os.makedirs(output_dir, exist_ok=True) - block_id = str(block_id) - - # write all indicators as "object_id, indicator_id" - for k, objs in solver.indicators.items(): - with open(os.path.join(output_dir, k + "_b" + block_id), 'w') as f: - for obj, v in objs.items(): - f.write(f"{obj} {v}\n") - - # write features for objective - indicators = {} - num_features = {} - for k, fn in solver.node_indicator_fn_map.items(): - for n_id, node in solver.graph.nodes(data=True): - ind = solver.indicators[k][n_id] - costs = fn(node) - indicators[ind] = (k, costs) - num_features[k] = len(costs) - for k, fn in solver.edge_indicator_fn_map.items(): - for u, v, edge in solver.graph.edges(data=True): - ind = solver.indicators[k][(u, v)] - costs = fn(edge) - indicators[ind] = (k, costs) - num_features[k] = len(costs) - - features_locs = {} - acc = 0 - for k, v in num_features.items(): - features_locs[k] = acc - acc += v - num_features = acc - assert sorted(indicators.keys()) == list(range(len(indicators))), \ - "some error reading indicators and features" - with open(os.path.join(output_dir, "features_b" + block_id), 'w') as f: - for ind in sorted(indicators.keys()): - k, costs = indicators[ind] - features = [0]*num_features - features_loc = features_locs[k] - features[features_loc:features_loc+len(costs)] = costs - f.write(" ".join([str(f) for f in features]) + "\n") - - # write constraints - def rel_to_str(rel): - if rel == pylp.Relation.Equal: - return " == " - elif rel == pylp.Relation.LessEqual: - return " <= " - elif rel == pylp.Relation.GreaterEqual: - return " >= " - else: - raise RuntimeError("invalid pylp.Relation: %s", rel) - - with open(os.path.join(output_dir, "constraints_b" + block_id), 'w') as f: - for constraint in solver.main_constraints: - val = constraint.get_value() - rel = rel_to_str(constraint.get_relation()) - coeffs = " ".join( - [f"{v}*{idx}" - for idx, v in constraint.get_coefficients().items()]) - f.write(f"{coeffs} {rel} {val}\n") From 094ffd0bf2c8222faf733356dbc0372d45fac05e Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 19 Jul 2022 16:50:00 -0400 Subject: [PATCH 211/263] reduce mongodb usage in tests --- tests/test_evaluation.py | 22 +- tests/test_greedy_baseline.py | 73 +++++- tests/test_solver.py | 425 ++++++++++++++++++++++++++++++++++ tests/test_tracks_source.py | 20 +- 4 files changed, 516 insertions(+), 24 deletions(-) create mode 100644 tests/test_solver.py diff --git a/tests/test_evaluation.py b/tests/test_evaluation.py index 477166b..ea97452 100644 --- a/tests/test_evaluation.py +++ b/tests/test_evaluation.py @@ -1,6 +1,7 @@ import logging import unittest -import pymongo + +import networkx as nx import daisy @@ -15,16 +16,12 @@ class EvaluationTestCase(unittest.TestCase): - def delete_db(self): - client = pymongo.MongoClient('localhost') - client.drop_database('test_eval') - def create_graph(self, cells, edges, roi): - db = linajea.utils.CandidateDatabase('test_eval', 'localhost') - graph = db[roi] + graph = nx.DiGraph() graph.add_nodes_from(cells) graph.add_edges_from(edges) - tg = linajea.tracking.TrackGraph(graph_data=graph, frame_key='t') + tg = linajea.tracking.TrackGraph(graph_data=graph, frame_key='t', + roi=roi) return tg def getTrack1(self): @@ -93,7 +90,6 @@ def test_perfect_evaluation(self): self.assertEqual(scores.correct_segments["1"], (3, 3)) self.assertEqual(scores.correct_segments["2"], (2, 2)) self.assertEqual(scores.correct_segments["3"], (1, 1)) - self.delete_db() def test_imperfect_evaluation(self): cells, edges, roi = self.getTrack1() @@ -127,7 +123,6 @@ def test_imperfect_evaluation(self): self.assertEqual(scores.correct_segments["1"], (2, 3)) self.assertEqual(scores.correct_segments["2"], (0, 2)) self.assertEqual(scores.correct_segments["3"], (0, 1)) - self.delete_db() def test_fn_division_evaluation(self): cells, edges, roi = self.getDivisionTrack() @@ -165,7 +160,6 @@ def test_fn_division_evaluation(self): self.assertEqual(scores.correct_segments["2"], (2, 4)) self.assertEqual(scores.correct_segments["3"], (0, 2)) self.assertEqual(scores.correct_segments["4"], (0, 1)) - self.delete_db() def test_fn_division_evaluation2(self): cells, edges, roi = self.getDivisionTrack() @@ -203,7 +197,6 @@ def test_fn_division_evaluation2(self): self.assertEqual(scores.correct_segments["1"], (4, 5)) self.assertEqual(scores.correct_segments["2"], (2, 3)) self.assertEqual(scores.correct_segments["3"], (0, 1)) - self.delete_db() def test_fn_division_evaluation3(self): cells, edges, roi = self.getDivisionTrack() @@ -234,7 +227,6 @@ def test_fn_division_evaluation3(self): self.assertEqual(scores.fp_divisions, 0) self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 5./6) - self.delete_db() def test_fp_division_evaluation(self): cells, edges, roi = self.getDivisionTrack() @@ -270,7 +262,6 @@ def test_fp_division_evaluation(self): self.assertEqual(scores.correct_segments["1"], (5, 5)) self.assertEqual(scores.correct_segments["2"], (2, 3)) self.assertEqual(scores.correct_segments["3"], (0, 1)) - self.delete_db() def test_fp_division_evaluation_at_beginning_of_gt(self): cells, edges, roi = self.getTrack1() @@ -306,7 +297,6 @@ def test_fp_division_evaluation_at_beginning_of_gt(self): self.assertEqual(scores.fp_divisions, 1) self.assertAlmostEqual(scores.precision, 1.0) self.assertAlmostEqual(scores.recall, 1.0) - self.delete_db() def test_one_off_fp_division_evaluation(self): roi = daisy.Roi((0, 0, 0, 0), (5, 5, 5, 5)) @@ -366,7 +356,6 @@ def test_one_off_fp_division_evaluation(self): self.assertEqual(scores.fn_divisions, 1) self.assertEqual(scores.fp_divisions, 1) self.assertEqual(scores.fn_edges, 0) - self.delete_db() def test_one_off_fp_division_evaluation2(self): roi = daisy.Roi((0, 0, 0, 0), (10, 5, 5, 5)) @@ -434,4 +423,3 @@ def test_one_off_fp_division_evaluation2(self): self.assertEqual(scores.fn_divisions, 1) self.assertEqual(scores.fp_divisions, 1) self.assertEqual(scores.fn_edges, 0) - self.delete_db() diff --git a/tests/test_greedy_baseline.py b/tests/test_greedy_baseline.py index 8757ca7..f9c2977 100644 --- a/tests/test_greedy_baseline.py +++ b/tests/test_greedy_baseline.py @@ -16,7 +16,72 @@ def delete_db(self, db_name, db_host): client = pymongo.MongoClient(db_host) client.drop_database(db_name) - def test_greedy_basic(self): + def test_greedy_basic_db(self): + # x + # 3| 1---2 \ + # 2| / -5 (x=2.1) + # 1| 0---3 / + # 0| \--4 + # ------------------------------------ t + # 0 1 2 3 + cells = [ + {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, + {'id': 1, 't': 0, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0}, + {'id': 2, 't': 1, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0}, + {'id': 3, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, + {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0}, + {'id': 5, 't': 2, 'z': 1, 'y': 1, 'x': 2.1, 'score': 2.0} + ] + + edges = [ + {'source': 3, 'target': 0, 'score': 1.0, 'distance': 0.0}, + {'source': 2, 'target': 1, 'score': 1.0, 'distance': 0.0}, + {'source': 5, 'target': 2, 'score': 1.0, 'distance': 0.9}, + {'source': 4, 'target': 3, 'score': 1.0, 'distance': 1.0}, + {'source': 5, 'target': 3, 'score': 1.0, 'distance': 1.1}, + ] + db_name = 'linajea_test_solver' + db_host = 'localhost' + graph_provider = linajea.utils.CandidateDatabase( + db_name, + db_host, + mode='w') + roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) + graph = graph_provider[roi] + graph.add_nodes_from([(cell['id'], cell) for cell in cells]) + graph.add_edges_from([(edge['source'], edge['target'], edge) + for edge in edges]) + graph.write_nodes() + graph.write_edges() + + linajea.tracking.greedy_track( + db_name=db_name, + db_host=db_host, + selected_key='selected_1', + metric='distance', + frame_key='t') + + read_db = linajea.utils.CandidateDatabase( + db_name, + db_host, + mode='r', + parameters_id=1) + selected_graph = read_db.get_selected_graph(roi) + + selected_edges = [] + for u, v, data in selected_graph.edges(data=True): + if data['selected_1']: + selected_edges.append((u, v)) + expected_result = [ + (3, 0), + (2, 1), + (4, 3), + (5, 2) + ] + self.assertCountEqual(selected_edges, expected_result) + self.delete_db(db_name, db_host) + + def test_greedy_basic_graph(self): # x # 3| 1---2 \ # 2| / -5 (x=2.1) @@ -52,7 +117,7 @@ def test_greedy_basic(self): graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.greedy_track( - graph, + graph=graph, selected_key='selected', metric='distance', frame_key='t') @@ -105,7 +170,7 @@ def test_greedy_split(self): graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.greedy_track( - graph, + graph=graph, selected_key='selected', metric='distance', frame_key='t') @@ -158,7 +223,7 @@ def test_greedy_node_threshold(self): graph.add_edges_from([(edge['source'], edge['target'], edge) for edge in edges]) linajea.tracking.greedy_track( - graph, + graph=graph, selected_key='selected', metric='distance', frame_key='t', diff --git a/tests/test_solver.py b/tests/test_solver.py new file mode 100644 index 0000000..9a381da --- /dev/null +++ b/tests/test_solver.py @@ -0,0 +1,425 @@ +import logging +import unittest + +import networkx as nx + +import daisy + +import linajea.config +import linajea.tracking +import linajea.tracking.track +import linajea.tracking.cost_functions +import linajea.utils + +logging.basicConfig(level=logging.INFO) + + +class TrackingConfig(): + def __init__(self, solve_config): + self.solve = solve_config + + +class TestSolver(unittest.TestCase): + + def test_solver_basic(self): + '''x + 3| /-4 + 2| /--3---5 + 1| 0---1 + 0| \\--2 + ------------------------------------ t + 0 1 2 3 + + Should select 0, 1, 2, 3, 5 + ''' + + cells = [ + {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, + {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, + {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0}, + {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0}, + {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0}, + {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0} + ] + + edges = [ + {'source': 1, 'target': 0, 'score': 1.0, + 'prediction_distance': 0.0}, + {'source': 2, 'target': 1, 'score': 1.0, + 'prediction_distance': 1.0}, + {'source': 3, 'target': 1, 'score': 1.0, + 'prediction_distance': 1.0}, + {'source': 4, 'target': 1, 'score': 1.0, + 'prediction_distance': 2.0}, + {'source': 5, 'target': 3, 'score': 1.0, + 'prediction_distance': 0.0}, + ] + roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) + ps = { + "track_cost": 4.0, + "weight_edge_score": 0.1, + "weight_node_score": -0.1, + "selection_constant": -1.0, + "max_cell_move": 0.0, + "block_size": [5, 100, 100, 100], + "context": [2, 100, 100, 100], + } + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=ps, job=job) + solve_config.solver_type = "basic" + config = TrackingConfig(solve_config) + + graph = nx.DiGraph() + graph.add_nodes_from([(cell['id'], cell) for cell in cells]) + graph.add_edges_from([(edge['source'], edge['target'], edge) + for edge in edges]) + graph = linajea.tracking.TrackGraph(graph, frame_key='t', roi=roi) + + linajea.tracking.track( + graph, + config, + frame_key='t', + selected_key='selected') + + selected_edges = [] + for u, v, data in graph.edges(data=True): + if data['selected']: + selected_edges.append((u, v)) + expected_result = [ + (1, 0), + (2, 1), + (3, 1), + (5, 3) + ] + self.assertCountEqual(selected_edges, expected_result) + + def test_solver_node_close_to_edge(self): + # x + # 3| /-4 + # 2| /--3 + # 1| 0---1 + # 0| \--2 + # ------------------------------------ t + # 0 1 2 + + cells = [ + {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, + {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, + {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0}, + {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0}, + {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 4, 'score': 2.0} + ] + + edges = [ + {'source': 1, 'target': 0, 'score': 1.0, + 'prediction_distance': 0.0}, + {'source': 2, 'target': 1, 'score': 1.0, + 'prediction_distance': 1.0}, + {'source': 3, 'target': 1, 'score': 1.0, + 'prediction_distance': 1.0}, + {'source': 4, 'target': 1, 'score': 1.0, + 'prediction_distance': 2.0}, + ] + roi = daisy.Roi((0, 0, 0, 0), (5, 5, 5, 5)) + ps = { + "track_cost": 4.0, + "weight_edge_score": 0.1, + "weight_node_score": -0.1, + "selection_constant": -1.0, + "max_cell_move": 1.0, + "block_size": [5, 100, 100, 100], + "context": [2, 100, 100, 100], + } + parameters = linajea.config.SolveParametersConfig(**ps) + + graph = nx.DiGraph() + graph.add_nodes_from([(cell['id'], cell) for cell in cells]) + graph.add_edges_from([(edge['source'], edge['target'], edge) + for edge in edges]) + graph = linajea.tracking.TrackGraph(graph, frame_key='t', roi=roi) + + close_fn = linajea.tracking.cost_functions.is_close_to_roi_border( + graph.roi, parameters.max_cell_move) + for node, data in graph.nodes(data=True): + close = close_fn(data) + if node in [2, 4]: + close = not close + self.assertFalse(close) + + def test_solver_multiple_configs(self): + # x + # 3| /-4 + # 2| /--3---5 + # 1| 0---1 + # 0| \--2 + # ------------------------------------ t + # 0 1 2 3 + + cells = [ + {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, + {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0}, + {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0}, + {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0}, + {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0}, + {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0} + ] + + edges = [ + {'source': 1, 'target': 0, 'score': 1.0, + 'prediction_distance': 0.0}, + {'source': 2, 'target': 1, 'score': 1.0, + 'prediction_distance': 1.0}, + {'source': 3, 'target': 1, 'score': 1.0, + 'prediction_distance': 1.0}, + {'source': 4, 'target': 1, 'score': 1.0, + 'prediction_distance': 2.0}, + {'source': 5, 'target': 3, 'score': 1.0, + 'prediction_distance': 0.0}, + ] + roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) + ps1 = { + "track_cost": 4.0, + "weight_edge_score": 0.1, + "weight_node_score": -0.1, + "selection_constant": -1.0, + "max_cell_move": 0.0, + "block_size": [5, 100, 100, 100], + "context": [2, 100, 100, 100], + } + ps2 = { + # Making all the values smaller increases the + # relative cost of division + "track_cost": 1.0, + "weight_edge_score": 0.01, + "weight_node_score": -0.01, + "selection_constant": -0.1, + "max_cell_move": 0.0, + "block_size": [5, 100, 100, 100], + "context": [2, 100, 100, 100], + } + parameters = [ps1, ps2] + keys = ['selected_1', 'selected_2'] + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=parameters, + job=job) + solve_config.solver_type = "basic" + config = TrackingConfig(solve_config) + + graph = nx.DiGraph() + graph.add_nodes_from([(cell['id'], cell) for cell in cells]) + graph.add_edges_from([(edge['source'], edge['target'], edge) + for edge in edges]) + graph = linajea.tracking.TrackGraph(graph, frame_key='t', roi=roi) + linajea.tracking.track( + graph, + config, + frame_key='t', + selected_key=keys) + + selected_edges_1 = [] + selected_edges_2 = [] + for u, v, data in graph.edges(data=True): + if data['selected_1']: + selected_edges_1.append((u, v)) + if data['selected_2']: + selected_edges_2.append((u, v)) + expected_result_1 = [ + (1, 0), + (2, 1), + (3, 1), + (5, 3) + ] + expected_result_2 = [ + (1, 0), + (3, 1), + (5, 3) + ] + self.assertCountEqual(selected_edges_1, expected_result_1) + self.assertCountEqual(selected_edges_2, expected_result_2) + + def test_solver_cell_state(self): + '''x + 3| /-4 + 2| /--3---5 + 1| 0---1 + 0| \\--2 + ------------------------------------ t + 0 1 2 3 + + Should select 0, 1, 2, 3, 5 + ''' + + cells = [ + {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 0, + "score_continuation": 1}, + {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, + 'score_mother': 1, + "score_daughter": 0, + "score_continuation": 0}, + {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 1, + "score_continuation": 0}, + {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 1, + "score_continuation": 0}, + {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 0, + "score_continuation": 1}, + {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 0, + "score_continuation": 1} + ] + + edges = [ + {'source': 1, 'target': 0, 'score': 1.0, + 'prediction_distance': 0.0}, + {'source': 2, 'target': 1, 'score': 1.0, + 'prediction_distance': 1.0}, + {'source': 3, 'target': 1, 'score': 1.0, + 'prediction_distance': 1.0}, + {'source': 4, 'target': 1, 'score': 1.0, + 'prediction_distance': 2.0}, + {'source': 5, 'target': 3, 'score': 1.0, + 'prediction_distance': 0.0}, + ] + roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) + ps = { + "track_cost": 4.0, + "weight_edge_score": 0.1, + "weight_node_score": -0.1, + "selection_constant": -1.0, + "weight_division": -0.1, + "weight_child": -0.1, + "weight_continuation": -0.1, + "division_constant": 1, + "max_cell_move": 0.0, + "block_size": [5, 100, 100, 100], + "context": [2, 100, 100, 100], + "cell_state_key": "vgg_score", + } + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=ps, job=job) + solve_config.solver_type = "cell_state" + config = TrackingConfig(solve_config) + + graph = nx.DiGraph() + graph.add_nodes_from([(cell['id'], cell) for cell in cells]) + graph.add_edges_from([(edge['source'], edge['target'], edge) + for edge in edges]) + graph = linajea.tracking.TrackGraph(graph, frame_key='t', roi=roi) + linajea.tracking.track( + graph, + config, + frame_key='t', + selected_key='selected') + + selected_edges = [] + for u, v, data in graph.edges(data=True): + if data['selected']: + selected_edges.append((u, v)) + expected_result = [ + (1, 0), + (2, 1), + (3, 1), + (5, 3) + ] + self.assertCountEqual(selected_edges, expected_result) + + def test_solver_cell_state2(self): + '''x + 3| /-4 + 2| /--3---5 + 1| 0---1 + 0| \\--2 + ------------------------------------ t + 0 1 2 3 + + Should select 0, 1, 3, 5 due to vgg predicting continuation + ''' + + cells = [ + {'id': 0, 't': 0, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 0, + "score_continuation": 1}, + {'id': 1, 't': 1, 'z': 1, 'y': 1, 'x': 1, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 0, + "score_continuation": 1}, + {'id': 2, 't': 2, 'z': 1, 'y': 1, 'x': 0, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 0, + "score_continuation": 1}, + {'id': 3, 't': 2, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 0, + "score_continuation": 1}, + {'id': 4, 't': 2, 'z': 1, 'y': 1, 'x': 3, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 0, + "score_continuation": 1}, + {'id': 5, 't': 3, 'z': 1, 'y': 1, 'x': 2, 'score': 2.0, + 'score_mother': 0, + "score_daughter": 0, + "score_continuation": 1}, + ] + + edges = [ + {'source': 1, 'target': 0, 'score': 1.0, + 'prediction_distance': 0.0}, + {'source': 2, 'target': 1, 'score': 1.0, + 'prediction_distance': 1.0}, + {'source': 3, 'target': 1, 'score': 1.0, + 'prediction_distance': 1.0}, + {'source': 4, 'target': 1, 'score': 1.0, + 'prediction_distance': 2.0}, + {'source': 5, 'target': 3, 'score': 1.0, + 'prediction_distance': 0.0}, + ] + roi = daisy.Roi((0, 0, 0, 0), (4, 5, 5, 5)) + ps = { + "track_cost": 4.0, + "weight_edge_score": 0.1, + "weight_node_score": -0.1, + "selection_constant": 0.0, + "weight_division": -0.1, + "weight_child": -0.1, + "weight_continuation": -0.1, + "division_constant": 1, + "max_cell_move": 0.0, + "block_size": [5, 100, 100, 100], + "context": [2, 100, 100, 100], + "cell_state_key": "score" + } + job = {"num_workers": 5, "queue": "normal"} + solve_config = linajea.config.SolveConfig(parameters=ps, job=job) + solve_config.solver_type = "cell_state" + config = TrackingConfig(solve_config) + + graph = nx.DiGraph() + graph.add_nodes_from([(cell['id'], cell) for cell in cells]) + graph.add_edges_from([(edge['source'], edge['target'], edge) + for edge in edges]) + graph = linajea.tracking.TrackGraph(graph, frame_key='t', roi=roi) + linajea.tracking.track( + graph, + config, + frame_key='t', + selected_key='selected') + + selected_edges = [] + for u, v, data in graph.edges(data=True): + if data['selected']: + selected_edges.append((u, v)) + expected_result = [ + (1, 0), + (3, 1), + (5, 3) + ] + self.assertCountEqual(selected_edges, expected_result) diff --git a/tests/test_tracks_source.py b/tests/test_tracks_source.py index e9c3bd8..c9199b1 100644 --- a/tests/test_tracks_source.py +++ b/tests/test_tracks_source.py @@ -47,7 +47,7 @@ def tearDown(self): def test_parent_location(self): points = gp.GraphKey("POINTS") ts = TracksSource( - TEST_FILE, + TEST_FILE_WITH_HEADER, points) request = gp.BatchRequest() @@ -67,6 +67,20 @@ def test_parent_location(self): self.assertListEqual([2.0, 2.0, 2.0, 2.0], list(points[3])) + def test_has_header(self): + points = gp.GraphKey("POINTS") + ts = TracksSource( + TEST_FILE, + points) + + request = gp.BatchRequest() + request.add( + points, + gp.Coordinate((5, 5, 5, 5))) + + with self.assertRaises(AssertionError): + ts.setup() + def test_csv_header(self): points = gp.GraphKey("POINTS") tswh = TracksSource( @@ -96,7 +110,7 @@ def test_delete_points_in_context(self): mask = gp.ArrayKey("MASK") radius = [0.1, 0.1, 0.1, 0.1] ts = TracksSource( - TEST_FILE, + TEST_FILE_WITH_HEADER, points) amv = AddMovementVectors( points, @@ -127,7 +141,7 @@ def test_add_movement_vectors(self): mask = gp.ArrayKey("MASK") radius = [0.1, 0.1, 0.1, 0.1] ts = TracksSource( - TEST_FILE, + TEST_FILE_WITH_HEADER, points) amv = AddMovementVectors( points, From 8948b1f24aa128c68c9193cf78c16e25bc42c360 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 19 Jul 2022 16:51:22 -0400 Subject: [PATCH 212/263] simplify parsing radius info from tracks file --- .../gunpowder_nodes/add_movement_vectors.py | 5 +- linajea/gunpowder_nodes/tracks_source.py | 82 +++++++---------- linajea/utils/parse_tracks_file.py | 89 ++++++------------- 3 files changed, 62 insertions(+), 114 deletions(-) diff --git a/linajea/gunpowder_nodes/add_movement_vectors.py b/linajea/gunpowder_nodes/add_movement_vectors.py index f56bf37..62be15f 100644 --- a/linajea/gunpowder_nodes/add_movement_vectors.py +++ b/linajea/gunpowder_nodes/add_movement_vectors.py @@ -221,8 +221,9 @@ def __draw_movement_vectors(self, points, data_roi, voxel_size, offset, # radius for current frame and use that to draw mask and vectors avg_object_radius = [] for point in points.nodes: - if point.attrs.get('value') is not None: - r = point.attrs['value'][0] + if point.attrs.get('value') is not None and \ + point.attrs['value'].get('radius') is not None: + r = point.attrs['value']['radius'] avg_object_radius.append(r) avg_object_radius = (int(np.ceil(np.mean(avg_object_radius))) if len(avg_object_radius) > 0 else object_radius) diff --git a/linajea/gunpowder_nodes/tracks_source.py b/linajea/gunpowder_nodes/tracks_source.py index 961b564..3cc8616 100644 --- a/linajea/gunpowder_nodes/tracks_source.py +++ b/linajea/gunpowder_nodes/tracks_source.py @@ -85,7 +85,7 @@ class TracksSource(BatchProvider): ''' def __init__(self, filename, points, points_spec=None, scale=1.0, - use_radius=False, subsampling_seed=42): + use_radius=False): self.filename = filename self.points = points @@ -97,7 +97,6 @@ def __init__(self, filename, points, points_spec=None, scale=1.0, self.use_radius = use_radius self.locations = None self.track_info = None - self.subsampling_seed = subsampling_seed def setup(self): @@ -155,41 +154,21 @@ def _get_points(self, point_filter): nodes = [] for location, track_info in zip(filtered_locations, filtered_track_info): - # frame of current point - t = location[0] - if not isinstance(self.use_radius, dict): - # if use_radius is boolean, take radius from file if set - value = track_info[3] if self.use_radius else None - else: - # otherwise use_radius should be a dict mapping from - # frame thresholds to radii - if len(self.use_radius.keys()) > 1: - value = None - for th in sorted(self.use_radius.keys()): - # find entry that is closest but larger than - # frame of current point - if t < int(th): - # get value object (list) from track info - value = track_info[3] - # radius stored at first position (None if not set) - value[0] = self.use_radius[th] - break - assert value is not None, \ - "verify value of use_radius in config" - else: - value = (track_info[3] if list(self.use_radius.values())[0] - else None) - + track_info["attrs"]["radius"] = self._set_point_radius(location, + track_info) + logger.debug("%s", track_info) + print(track_info) node = TrackNode( # point_id - track_info[0], + track_info["cell_id"], location, # parent_id - track_info[1] if track_info[1] > 0 else None, + track_info["parent_id"] + if track_info["parent_id"] > 0 else None, # track_id - track_info[2], - # radius - value=value) + track_info["track_id"], + # optional attributes, e.g. radius + value=track_info["attrs"]) nodes.append(node) return nodes @@ -199,20 +178,27 @@ def _read_points(self): self.filename, scale=self.scale, limit_to_roi=roi) - cnt_points = len(self.locations) - rng = np.random.default_rng(self.subsampling_seed) - shuffled_norm_idcs = rng.permutation(cnt_points)/(cnt_points-1) - logger.debug("permutation (seed %s): %s (min %s, max %s, cnt %s)", - self.subsampling_seed, - shuffled_norm_idcs, - np.min(shuffled_norm_idcs), - np.max(shuffled_norm_idcs), - len(shuffled_norm_idcs)) - if self.track_info.dtype == object: - for idx, tri in zip(shuffled_norm_idcs, self.track_info): - tri[3].append(idx) + + def _set_point_radius(self, location, track_info): + t = location[0] + radius = None + if not isinstance(self.use_radius, dict): + # if use_radius is boolean, take radius from file if set + if self.use_radius is True: + radius = track_info["attrs"].get("radius", + track_info["attrs"][0]) else: - self.track_info = np.concatenate( - (self.track_info, np.reshape(shuffled_norm_idcs, - shuffled_norm_idcs.shape + (1,))), - axis=1, dtype=object) + # otherwise use_radius should be a dict mapping from + # frame thresholds to radii + if len(self.use_radius.keys()) > 1: + for th in sorted(self.use_radius.keys()): + # find entry that is closest but larger than + # frame of current point + if t < int(th): + radius = self.use_radius[th] + break + assert radius is not None, \ + "verify value of use_radius in config" + else: + radius = list(self.use_radius.values())[0] + return radius diff --git a/linajea/utils/parse_tracks_file.py b/linajea/utils/parse_tracks_file.py index 53f7265..29de63a 100644 --- a/linajea/utils/parse_tracks_file.py +++ b/linajea/utils/parse_tracks_file.py @@ -17,61 +17,7 @@ def parse_tracks_file( filename, scale=1.0, limit_to_roi=None): - dialect, has_header = _get_dialect_and_header(filename) - logger.debug("Tracks file has header: %s" % has_header) - if has_header: - locations, track_info = \ - _parse_csv_fields(filename, scale, limit_to_roi) - else: - locations, track_info = \ - _parse_csv_ndims(filename, scale, limit_to_roi) - return locations, track_info - - -def _get_dialect_and_header(csv_file): - with open(csv_file, 'r') as f: - dialect = csv.Sniffer().sniff(f.read(1024)) - f.seek(0) - has_header = csv.Sniffer().has_header(f.read(1024)) - - return dialect, has_header - - -def _parse_csv_ndims(filename, scale=1.0, limit_to_roi=None, read_dims=4): - '''Read one point per line. If ``read_dims`` is 0, all values - in one line are considered as the location of the point. If positive, - only the first ``ndims`` are used. If negative, all but the last - ``-ndims`` are used. Defaults to 4, so the first 4 values are considered - locations t z y x. - ''' - with open(filename, 'r') as f: - tokens = [[t.strip(',') for t in line.split()] - for line in f] - try: - _ = int(tokens[0][-1]) - ldim = None - except ValueError: - ldim = -1 - - locations = [] - track_info = [] - for line in tokens: - loc = np.array([float(d) for d in line[:read_dims]]) * scale - if limit_to_roi is not None and \ - not limit_to_roi.contains(Coordinate(loc)): - continue - - locations.append(loc) - track_info.append( - np.array([int(i.split(".")[0]) - for i in line[read_dims:ldim]])) - - return (np.array(locations, dtype=np.float32), - np.array(track_info, dtype=np.int32)) - - -def _parse_csv_fields(filename, scale=1.0, limit_to_roi=None): - '''Read one point per line. Assumes a header with the following required + '''Read one point per line. Expects a header with the following required fields: t z @@ -80,14 +26,16 @@ def _parse_csv_fields(filename, scale=1.0, limit_to_roi=None): cell_id parent_id track_id - And these optional fields: + And optional fields such as: radius name div_state ''' + dialect, has_header = _get_dialect_and_header(filename) + assert has_header, "Please provide a tracks file with a header line" + locations = [] track_info = [] - dialect, has_header = _get_dialect_and_header(filename) with open(filename, 'r') as f: assert has_header, "No header found, but this function needs a header" reader = csv.DictReader(f, fieldnames=None, @@ -102,13 +50,26 @@ def _parse_csv_fields(filename, scale=1.0, limit_to_roi=None): not limit_to_roi.contains(Coordinate(loc)): continue locations.append(loc) - track_info.append( - [int(row['cell_id']), - int(row['parent_id']), - int(row['track_id']), - [float(row['radius']) if 'radius' in row else None, - row.get("name")]]) + ti = {"cell_id": int(row['cell_id']), + "parent_id": int(row['parent_id']), + "track_id": int(row['track_id'])} + attrs = {} + if "radius" in row: + attrs["radius"] = float(row['radius']) + if "name" in row: + attrs["name"] = row['name'] if 'div_state' in row: - track_info[-1].append(int(row['div_state'])) + attrs["div_state"] = int(row['div_state']) + ti["attrs"] = attrs + track_info.append(ti) return np.array(locations), np.array(track_info, dtype=object) + + +def _get_dialect_and_header(csv_file): + with open(csv_file, 'r') as f: + dialect = csv.Sniffer().sniff(f.read(1024)) + f.seek(0) + has_header = csv.Sniffer().has_header(f.read(1024)) + + return dialect, has_header From 18befd3921d540d5f97b066a4476ebe76512d228 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 19 Jul 2022 16:52:18 -0400 Subject: [PATCH 213/263] rename some more instances of parent->movement --- .../process_blockwise/predict_blockwise.py | 4 +-- linajea/run_scripts/config_example.toml | 2 +- .../config_example_drosophila.toml | 14 +++------ linajea/run_scripts/config_example_mouse.toml | 14 +++------ .../config_example_single_sample_train.toml | 2 +- linajea/training/torch_model.py | 31 ++++++++++--------- 6 files changed, 31 insertions(+), 36 deletions(-) diff --git a/linajea/process_blockwise/predict_blockwise.py b/linajea/process_blockwise/predict_blockwise.py index 00a9290..7c8f0cf 100644 --- a/linajea/process_blockwise/predict_blockwise.py +++ b/linajea/process_blockwise/predict_blockwise.py @@ -80,7 +80,7 @@ def predict_blockwise(linajea_config): # prepare output zarr, if necessary if linajea_config.predict.write_to_zarr: - parent_vectors_ds = 'volumes/parent_vectors' + movement_vectors_ds = 'volumes/movement_vectors' cell_indicator_ds = 'volumes/cell_indicator' maxima_ds = 'volumes/maxima' output_path = os.path.join(setup_dir, output_zarr) @@ -90,7 +90,7 @@ def predict_blockwise(linajea_config): daisy.prepare_ds( output_path, - parent_vectors_ds, + movement_vectors_ds, file_roi, voxel_size, dtype=np.float32, diff --git a/linajea/run_scripts/config_example.toml b/linajea/run_scripts/config_example.toml index 162480b..fdfd083 100644 --- a/linajea/run_scripts/config_example.toml +++ b/linajea/run_scripts/config_example.toml @@ -46,7 +46,7 @@ train_only_cell_indicator = false [train] val_log_step = 25 # radius for binary map -> *2 (in world units) -# in which to draw parent vectors (not used if use_radius) +# in which to draw movement vectors (not used if use_radius) object_radius = [ 0.1, 8.0, 8.0, 8.0,] # upper bound for dist cell moved between two frames (needed for context) move_radius = 25 diff --git a/linajea/run_scripts/config_example_drosophila.toml b/linajea/run_scripts/config_example_drosophila.toml index b638c96..b284b75 100644 --- a/linajea/run_scripts/config_example_drosophila.toml +++ b/linajea/run_scripts/config_example_drosophila.toml @@ -24,24 +24,22 @@ kernel_size_up = [[ [3, 3, 3], [3, 3, 3],], upsampling = "trilinear" average_vectors = false nms_window_shape = [ 3, 15, 15,] -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/mknet_drosophila.py" latent_temp_conv = false train_only_cell_indicator = false [train] # radius for binary map -> *2 (in world units) -# in which to draw parent vectors (not used if use_radius) -parent_radius = [ 0.1, 10.0, 10.0, 10.0,] +# in which to draw movement vectors (not used if use_radius) +object_radius = [ 0.1, 10.0, 10.0, 10.0,] # sigma for Gauss -> ~*4 (5 in z -> in 3 slices) (not used if use_radius) rasterize_radius = [ 0.1, 5.0, 5.0, 5.0,] # upper bound for dist cell moved between two frames (needed for context) move_radius = 10 cache_size = 1 use_radius = false -parent_vectors_loss_transition_offset = 20000 -parent_vectors_loss_transition_factor = 0.01 -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/train_drosophila.py" +movement_vectors_loss_transition_offset = 20000 +movement_vectors_loss_transition_factor = 0.01 max_iterations = 11 checkpoint_stride = 10 snapshot_stride = 5 @@ -89,9 +87,7 @@ eps = 1e-8 [predict] -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/predict.py" -path_to_script_db_from_zarr = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/write_cells.py" -output_zarr_prefix = "/nrs/funke/hirschp/linajea_experiments" +output_zarr_dir = "/nrs/funke/hirschp/linajea_experiments" write_to_zarr = false write_to_db = true write_db_from_zarr = false diff --git a/linajea/run_scripts/config_example_mouse.toml b/linajea/run_scripts/config_example_mouse.toml index 5a2cdad..7cb01c7 100644 --- a/linajea/run_scripts/config_example_mouse.toml +++ b/linajea/run_scripts/config_example_mouse.toml @@ -24,24 +24,22 @@ kernel_size_up = [[ [3, 3, 3], [3, 3, 3],], upsampling = "trilinear" average_vectors = false nms_window_shape = [ 5, 21, 21,] -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/mknet_mouse.py" latent_temp_conv = false train_only_cell_indicator = false [train] # radius for binary map -> *2 (in world units) -# in which to draw parent vectors (not used if use_radius) -parent_radius = [ 0.1, 10.0, 10.0, 10.0,] +# in which to draw movement vectors (not used if use_radius) +object_radius = [ 0.1, 10.0, 10.0, 10.0,] # sigma for Gauss -> ~*4 (5 in z -> in 3 slices) (not used if use_radius) rasterize_radius = [ 0.1, 5.0, 5.0, 5.0,] # upper bound for dist cell moved between two frames (needed for context) move_radius = 10 cache_size = 40 use_radius = false -parent_vectors_loss_transition_offset = 20000 -parent_vectors_loss_transition_factor = 0.01 -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/train_mouse.py" +movement_vectors_loss_transition_offset = 20000 +movement_vectors_loss_transition_factor = 0.01 max_iterations = 400001 checkpoint_stride = 25000 snapshot_stride = 1000 @@ -89,9 +87,7 @@ eps = 1e-8 [predict] -path_to_script = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/predict.py" -path_to_script_db_from_zarr = "/groups/funke/home/hirschp/linajea_experiments/unet_setups/write_cells.py" -output_zarr_prefix = "/nrs/funke/hirschp/linajea_experiments" +output_zarr_dir = "/nrs/funke/hirschp/linajea_experiments" write_to_zarr = false write_to_db = true write_db_from_zarr = false diff --git a/linajea/run_scripts/config_example_single_sample_train.toml b/linajea/run_scripts/config_example_single_sample_train.toml index 3c16ee1..61fe68c 100644 --- a/linajea/run_scripts/config_example_single_sample_train.toml +++ b/linajea/run_scripts/config_example_single_sample_train.toml @@ -56,7 +56,7 @@ train_only_cell_indicator = false [train] # val_log_step = 25 # radius for binary map -> *2 (in world units) -# in which to draw parent vectors (not used if use_radius) +# in which to draw movement vectors (not used if use_radius) object_radius = [ 0.1, 8.0, 8.0, 8.0,] # upper bound for dist cell moved between two frames (needed for context) move_radius = 25 diff --git a/linajea/training/torch_model.py b/linajea/training/torch_model.py index d40bb35..9f4b316 100644 --- a/linajea/training/torch_model.py +++ b/linajea/training/torch_model.py @@ -76,8 +76,8 @@ def __init__(self, config, current_step=0): self.cell_indicator_batched = ConvPass(num_fmaps[0], 1, [[1, 1, 1]], activation='Sigmoid') - self.parent_vectors_batched = ConvPass(num_fmaps[1], 3, [[1, 1, 1]], - activation=None) + self.movement_vectors_batched = ConvPass(num_fmaps[1], 3, [[1, 1, 1]], + activation=None) self.nms = torch.nn.MaxPool3d(config.model.nms_window_shape, stride=1, padding=0) @@ -107,7 +107,7 @@ def init_weights_sig(m): self.apply(init_weights) self.cell_indicator_batched.apply(init_weights_sig) if not self.config.model.train_only_cell_indicator: - self.parent_vectors_batched.apply(init_weights) + self.movement_vectors_batched.apply(init_weights) def inout_shapes(self, device): logger.info("getting train/test output shape by running model twice") @@ -166,19 +166,22 @@ def forward(self, raw): cell_indicator = torch.reshape(cell_indicator_batched, output_shape_1) if self.config.model.unet_style == "single": - parent_vectors_batched = self.parent_vectors_batched(model_out[0]) - parent_vectors = torch.reshape(parent_vectors_batched, - [3] + output_shape_1) + movement_vectors_batched = self.movement_vectors_batched( + model_out[0]) + movement_vectors = torch.reshape(movement_vectors_batched, + [3] + output_shape_1) elif (self.config.model.unet_style == "split" and not self.config.model.train_only_cell_indicator): model_par_vec = self.unet_par_vec(raw) - parent_vectors_batched = self.parent_vectors_batched(model_par_vec) - parent_vectors = torch.reshape(parent_vectors_batched, - [3] + output_shape_1) + movement_vectors_batched = self.movement_vectors_batched( + model_par_vec) + movement_vectors = torch.reshape(movement_vectors_batched, + [3] + output_shape_1) else: # self.config.model.unet_style == "multihead" - parent_vectors_batched = self.parent_vectors_batched(model_out[1]) - parent_vectors = torch.reshape(parent_vectors_batched, - [3] + output_shape_1) + movement_vectors_batched = self.movement_vectors_batched( + model_out[1]) + movement_vectors = torch.reshape(movement_vectors_batched, + [3] + output_shape_1) maxima = self.nms(cell_indicator_batched) if not self.training: @@ -202,7 +205,7 @@ def forward(self, raw): cell_indicator = crop(cell_indicator, output_shape_2) maxima = torch.eq(maxima, cell_indicator) if not self.config.model.train_only_cell_indicator: - parent_vectors = crop(parent_vectors, output_shape_2) + movement_vectors = crop(movement_vectors, output_shape_2) else: cell_indicator_cropped = crop(cell_indicator, output_shape_2) maxima = torch.eq(maxima, cell_indicator_cropped) @@ -218,4 +221,4 @@ def forward(self, raw): if self.config.model.train_only_cell_indicator: return cell_indicator, maxima, raw_cropped else: - return cell_indicator, maxima, raw_cropped, parent_vectors + return cell_indicator, maxima, raw_cropped, movement_vectors From e4fff566883ae96dee862fc9d9bb4f0c69e88791 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Tue, 19 Jul 2022 17:43:15 -0400 Subject: [PATCH 214/263] add some more docstrings and asserts to tracking code --- linajea/tracking/cost_functions.py | 23 +++++++++++++++++++++ linajea/tracking/track.py | 33 ++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/linajea/tracking/cost_functions.py b/linajea/tracking/cost_functions.py index 0ecac4a..5c8730a 100644 --- a/linajea/tracking/cost_functions.py +++ b/linajea/tracking/cost_functions.py @@ -83,6 +83,19 @@ def is_close(obj): def get_default_node_indicator_costs(config, parameters, graph): + """Get a predefined map of node indicator costs functions + + Args + ---- + config: TrackingConfig + Configuration object used, should contain information on which solver + type to use. + parameters: SolveParametersConfig + Current set of weights and parameters used to compute costs. + graph: TrackGraph + Graph containing the node candidates for which the costs will be + computed. + """ if parameters.feature_func == "noop": feature_func = lambda x: x # noqa: E731 elif parameters.feature_func == "log": @@ -129,6 +142,16 @@ def get_default_node_indicator_costs(config, parameters, graph): def get_default_edge_indicator_costs(config, parameters): + """Get a predefined map of edge indicator costs functions + + Args + ---- + config: TrackingConfig + Configuration object used, should contain information on which solver + type to use. + parameters: SolveParametersConfig + Current set of weights and parameters used to compute costs. + """ if parameters.feature_func == "noop": feature_func = lambda x: x # noqa: E731 elif parameters.feature_func == "log": diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index 97f23f9..a601075 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -49,19 +49,23 @@ def track(graph, config, selected_key, frame_key='t', Callable that returns a dict of str: Callable. Will be called once per set of parameters. - One entry per type of node indicator the solver should have; + See cost_functions.py:get_default_node_indicator_costs for an + example. + One entry per type of node indicator the solver should have. The Callable stored in the value of each entry will be called - on each node and should return the cost for this indicator in - the objective. + on each node and should return a list of costs for this indicator + in the objective. edge_indicator_costs (Callable): Callable that returns a dict of str: Callable. Will be called once per set of parameters. - One entry per type of edge indicator the solver should have; + See cost_functions.py:get_default_edge_indicator_costs for an + example. + One entry per type of edge indicator the solver should have. The Callable stored in the value of each entry will be called - on each edge and should return the cost for this indicator in - the objective. + on each edge and should return a list of costs for this indicator + in the objective. constraints_fns (list of Callable) @@ -115,21 +119,28 @@ def track(graph, config, selected_key, frame_key='t', total_solve_time = 0 if config.solve.solver_type is not None: + assert (not pin_constraints_fns and + not constraints_fns), ( + "Either set solve.solver_type or " + "explicitly provide lists of constraint functions") constrs = constraints.get_default_constraints(config) pin_constraints_fns = constrs[0] constraints_fns = constrs[1] for parameters, key in zip(parameters_sets, selected_key): - if node_indicator_costs is None: + # set costs depending on current set of parameters + if config.solve.solver_type is not None: + assert (not node_indicator_costs and + not edge_indicator_costs), ( + "Either set solve.solver_type or " + "explicitly provide cost functions") _node_indicator_costs = get_default_node_indicator_costs( config, parameters, track_graph) - else: - _node_indicator_costs = node_indicator_costs(config, parameters, - track_graph) - if edge_indicator_costs is None: _edge_indicator_costs = get_default_edge_indicator_costs( config, parameters) else: + _node_indicator_costs = node_indicator_costs(config, parameters, + track_graph) _edge_indicator_costs = edge_indicator_costs(config, parameters, track_graph) From 18c80ec99b84a5ac536957b539f3d114595cf509 Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 22 Jul 2022 16:03:14 -0400 Subject: [PATCH 215/263] skip CandidateDatabase tests if no mongodb server --- tests/test_candidate_database.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_candidate_database.py b/tests/test_candidate_database.py index 4d08d5b..952db6a 100644 --- a/tests/test_candidate_database.py +++ b/tests/test_candidate_database.py @@ -15,6 +15,13 @@ class DatabaseTestCase(TestCase): + def setUp(self): + db_host = 'localhost' + try: + _ = pymongo.MongoClient(db_host, serverSelectionTimeoutMS=0) + except pymongo.errors.ServerSelectionTimeoutError: + self.skipTest("No MongoDB server found") + def delete_db(self, db_name, db_host): client = pymongo.MongoClient(db_host) client.drop_database(db_name) From 702b0792c354b7117d2a8cd58e2d5e5a34c5d4eb Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 22 Jul 2022 16:05:54 -0400 Subject: [PATCH 216/263] refactoring: change cost func interface --- linajea/process_blockwise/solve_blockwise.py | 24 ++-- linajea/tracking/constraints.py | 4 +- linajea/tracking/cost_functions.py | 122 ++++++++++--------- linajea/tracking/solver.py | 42 ++++--- linajea/tracking/track.py | 28 ++--- 5 files changed, 117 insertions(+), 103 deletions(-) diff --git a/linajea/process_blockwise/solve_blockwise.py b/linajea/process_blockwise/solve_blockwise.py index cdf7a60..5d66ca9 100644 --- a/linajea/process_blockwise/solve_blockwise.py +++ b/linajea/process_blockwise/solve_blockwise.py @@ -265,18 +265,18 @@ def write_struct_svm(solver, block_id, output_dir): # write features for objective indicators = {} num_features = {} - for k, fn in solver.node_indicator_fn_map.items(): + for k, fns in solver.node_indicator_costs.items(): for n_id, node in solver.graph.nodes(data=True): ind = solver.indicators[k][n_id] - costs = fn(node) - indicators[ind] = (k, costs) - num_features[k] = len(costs) - for k, fn in solver.edge_indicator_fn_map.items(): + features = [fn(node)[0] for fn in fns] + indicators[ind] = (k, features) + num_features[k] = len(features) + for k, fns in solver.edge_indicator_costs.items(): for u, v, edge in solver.graph.edges(data=True): ind = solver.indicators[k][(u, v)] - costs = fn(edge) - indicators[ind] = (k, costs) - num_features[k] = len(costs) + features = [fn(edge) for fn in fns] + indicators[ind] = (k, features) + num_features[k] = len(features) features_locs = {} acc = 0 @@ -288,11 +288,11 @@ def write_struct_svm(solver, block_id, output_dir): "some error reading indicators and features" with open(os.path.join(output_dir, "features_b" + block_id), 'w') as f: for ind in sorted(indicators.keys()): - k, costs = indicators[ind] - features = [0]*num_features + k, features = indicators[ind] + all_features = [0]*num_features features_loc = features_locs[k] - features[features_loc:features_loc+len(costs)] = costs - f.write(" ".join([str(f) for f in features]) + "\n") + all_features[features_loc:features_loc+len(features)] = features + f.write(" ".join([str(f) for f in all_features]) + "\n") # write constraints def rel_to_str(rel): diff --git a/linajea/tracking/constraints.py b/linajea/tracking/constraints.py index fcca74e..9822fe6 100644 --- a/linajea/tracking/constraints.py +++ b/linajea/tracking/constraints.py @@ -299,5 +299,5 @@ def get_default_constraints(config): raise RuntimeError("solver_type %s unknown for constraints", solver_type) - return (pin_constraints_fn_list, - constraints_fn_list) + return (constraints_fn_list, + pin_constraints_fn_list) diff --git a/linajea/tracking/cost_functions.py b/linajea/tracking/cost_functions.py index 5c8730a..469e0a7 100644 --- a/linajea/tracking/cost_functions.py +++ b/linajea/tracking/cost_functions.py @@ -1,6 +1,23 @@ -"""Provides a set of cost functions to use in solver - -Should return a list of costs that will be applied to some indicator +"""Provides a set of cost functions for use in solver + +Each cost function should return a pair (feature, weight) that, multiplied +togther, results in the cost. +The feature can be constant (typically 1), then it is the same for all +indicators of the respective type, or it can be variable, e.g. a score per +node or edge candidate. + +get_default_node_indicator_costs and get_default_edge_indicator_costs can be +used to get the costs for all indicators for the basic and the cell state +setup. +User-provided functions should have the interface: +fn(params: SolveParametersConfig, graph: TrackGraph) -> + fn_map: {dict str: list of Callable} +params: ILP weights that should be used +graph: Graph that the ILP will be solved on +fn_map: Map that should contain one entry per type of indicator. Each indicator + can have multiple cost functions associated to it. + {"indicator_name": [list of cost functions], ...} + See get_default_node_indicator_costs for an example of such a map. """ import logging @@ -9,39 +26,24 @@ logger = logging.getLogger(__name__) -def score_times_weight_plus_th_costs_fn(weight, threshold, key="score", - feature_func=lambda x: x): - - def cost_fn(obj): - # feature_func(obj score) times a weight plus a threshold - score_costs = [feature_func(obj[key]) * weight, threshold] - logger.debug("set score times weight plus th costs %s", score_costs) - return score_costs - - return cost_fn - +def feature_times_weight_costs_fn(weight, key="score", + feature_func=lambda x: x): -def score_times_weight_costs_fn(weight, key="score", - feature_func=lambda x: x): + def fn(obj): + feature = feature_func(obj[key]) + return feature, weight - def cost_fn(obj): - # feature_func(obj score) times a weight - score_costs = [feature_func(obj[key]) * weight] - logger.debug("set score times weight costs %s", score_costs) - return score_costs - - return cost_fn + return fn def constant_costs_fn(weight, zero_if_true=lambda _: False): - def cost_fn(obj): - costs = [0] if zero_if_true(obj) else [weight] - logger.debug("set constant costs if %s = True costs %s", - zero_if_true(obj), costs) - return costs + def fn(obj): + feature = 1 + cond_weight = 0 if zero_if_true(obj) else weight + return feature, cond_weight - return cost_fn + return fn def is_nth_frame(n, frame_key='t'): @@ -108,32 +110,37 @@ def get_default_node_indicator_costs(config, parameters, graph): solver_type = config.solve.solver_type fn_map = { - "node_selected": - score_times_weight_plus_th_costs_fn( - parameters.weight_node_score, - parameters.selection_constant, - key="score", feature_func=feature_func), - "node_appear": constant_costs_fn( - parameters.track_cost, - zero_if_true=lambda obj: ( - is_nth_frame(graph.begin)(obj) or - (config.solve.check_node_close_to_roi and - is_close_to_roi_border( - graph.roi, parameters.max_cell_move)(obj)))) + "node_selected": [ + feature_times_weight_costs_fn( + parameters.weight_node_score, + key="score", feature_func=feature_func), + constant_costs_fn(parameters.selection_constant)], + "node_appear": [ + constant_costs_fn( + parameters.track_cost, + zero_if_true=lambda obj: ( + is_nth_frame(graph.begin)(obj) or + (config.solve.check_node_close_to_roi and + is_close_to_roi_border( + graph.roi, parameters.max_cell_move)(obj))))] } if solver_type == "basic": - fn_map["node_split"] = constant_costs_fn(1) + fn_map["node_split"] = [ + constant_costs_fn(1)] elif solver_type == "cell_state": - fn_map["node_split"] = score_times_weight_plus_th_costs_fn( - parameters.weight_division, - parameters.division_constant, - key="score_mother", feature_func=feature_func) - fn_map["node_child"] = score_times_weight_costs_fn( - parameters.weight_child, - key="score_daughter", feature_func=feature_func) - fn_map["node_continuation"] = score_times_weight_costs_fn( - parameters.weight_continuation, - key="score_continuation", feature_func=feature_func) + fn_map["node_split"] = [ + feature_times_weight_costs_fn( + parameters.weight_division, + key="score_mother", feature_func=feature_func), + constant_costs_fn(parameters.division_constant)] + fn_map["node_child"] = [ + feature_times_weight_costs_fn( + parameters.weight_child, + key="score_daughter", feature_func=feature_func)] + fn_map["node_continuation"] = [ + feature_times_weight_costs_fn( + parameters.weight_continuation, + key="score_continuation", feature_func=feature_func)] else: logger.info("solver_type %s unknown for node indicators, skipping", solver_type) @@ -151,6 +158,9 @@ def get_default_edge_indicator_costs(config, parameters): type to use. parameters: SolveParametersConfig Current set of weights and parameters used to compute costs. + graph: TrackGraph + Graph containing the node candidates for which the costs will be + computed (not used for the default edge costs). """ if parameters.feature_func == "noop": feature_func = lambda x: x # noqa: E731 @@ -164,10 +174,10 @@ def get_default_edge_indicator_costs(config, parameters): solver_type = config.solve.solver_type fn_map = { - "edge_selected": - score_times_weight_costs_fn(parameters.weight_edge_score, - key="prediction_distance", - feature_func=feature_func) + "edge_selected": [ + feature_times_weight_costs_fn(parameters.weight_edge_score, + key="prediction_distance", + feature_func=feature_func)] } if solver_type == "basic": pass diff --git a/linajea/tracking/solver.py b/linajea/tracking/solver.py index 156dd04..39f85a1 100644 --- a/linajea/tracking/solver.py +++ b/linajea/tracking/solver.py @@ -2,6 +2,8 @@ """ import logging +import numpy as np + import pylp logger = logging.getLogger(__name__) @@ -156,10 +158,10 @@ def update_objective(self, node_indicator_costs, edge_indicator_costs, Args ---- - node_indicator_costs: dict str: Callable - Map from (node) indicator type to Callable. The Callable will be - executed for every indicator of the respective type. It returns - a list of costs for that indicator. The sum of costs will be + node_indicator_costs: dict str: list of Callable + Map from (node) indicator type to list of Callable. Each Callable + will be executed for every indicator of the respective type. It + returns a cost for that indicator. The sum of costs will be added as a coefficient for that indicator to the objective. See tracking/cost_functions.py for examples. Interface: fn(obj: dict[str, Number]) -> cost: list[Number] @@ -171,10 +173,10 @@ def update_objective(self, node_indicator_costs, edge_indicator_costs, cost: The computed cost that will be added to the objective for the respective indicator - edge_indicator_costs: dict str: Callable - Map from (edge) indicator type to Callable. The Callable will be - executed for every indicator of the respective type. It returns - a list of costs for that indicator. The sum of costs will be + edge_indicator_costs: dict str: list of Callable + Map from (edge) indicator type to Callable. Each Callable + will be executed for every indicator of the respective type. It + returns a costs for that indicator. The sum of costs will be added as a coefficient for that indicator to the objective. See tracking/cost_functions.py for examples. Interface: fn(obj: dict[str, Number]) -> cost: list[Number] @@ -281,25 +283,25 @@ def _create_objective(self): objective = pylp.LinearObjective(self.num_vars) # node costs - for k, fn in self.node_indicator_costs.items(): + for k, fns in self.node_indicator_costs.items(): + assert isinstance(fns, list), ( + f"Please provide a list of cost functions for each indicator " + f"(indicator {k}: {fns})") for n_id, node in self.graph.nodes(data=True): objective.set_coefficient(self.indicators[k][n_id], - sum(fn(node))) + sum(np.prod(fn(node)) for fn in fns)) # edge costs - for k, fn in self.edge_indicator_costs.items(): + for k, fns in self.edge_indicator_costs.items(): + assert isinstance(fns, list), ( + f"Please provide a list of cost functions for each indicator " + f"(indicator {k}: {fns})") for u, v, edge in self.graph.edges(data=True): objective.set_coefficient(self.indicators[k][(u, v)], - sum(fn(edge))) + sum(np.prod(fn(edge)) for fn in fns)) self.objective = objective - def _create_constraints(self): - - self.main_constraints = [] - - self._add_constraints() - def _add_pin_constraints(self): logger.debug("setting pin constraints: %s", @@ -309,7 +311,9 @@ def _add_pin_constraints(self): self.pin_constraints.extend( fn(self.graph, self.indicators, self.selected_key)) - def _add_constraints(self): + def _create_constraints(self): + + self.main_constraints = [] logger.debug("setting constraints: %s", self.constraints_fns) diff --git a/linajea/tracking/track.py b/linajea/tracking/track.py index a601075..dc185ef 100644 --- a/linajea/tracking/track.py +++ b/linajea/tracking/track.py @@ -47,25 +47,25 @@ def track(graph, config, selected_key, frame_key='t', node_indicator_costs (Callable): - Callable that returns a dict of str: Callable. + Callable that returns a dict of str: list of Callable. Will be called once per set of parameters. See cost_functions.py:get_default_node_indicator_costs for an example. - One entry per type of node indicator the solver should have. - The Callable stored in the value of each entry will be called - on each node and should return a list of costs for this indicator - in the objective. + The dict should have one entry per type of node indicator. + The value of each entry is a list of Callables. Each Callable will + be called on each node and should return a cost. The sum of costs + is the cost for this indicator in the objective. edge_indicator_costs (Callable): - Callable that returns a dict of str: Callable. + Callable that returns a dict of str: list of Callable. Will be called once per set of parameters. See cost_functions.py:get_default_edge_indicator_costs for an example. - One entry per type of edge indicator the solver should have. - The Callable stored in the value of each entry will be called - on each edge and should return a list of costs for this indicator - in the objective. + The dict should have one entry per type of edge indicator. + The value of each entry is a list of Callables. Each Callable will + be called on each edge and should return a cost. The sum of costs + is the cost for this indicator in the objective. constraints_fns (list of Callable) @@ -124,8 +124,8 @@ def track(graph, config, selected_key, frame_key='t', "Either set solve.solver_type or " "explicitly provide lists of constraint functions") constrs = constraints.get_default_constraints(config) - pin_constraints_fns = constrs[0] - constraints_fns = constrs[1] + constraints_fns = constrs[0] + pin_constraints_fns = constrs[1] for parameters, key in zip(parameters_sets, selected_key): # set costs depending on current set of parameters @@ -139,9 +139,9 @@ def track(graph, config, selected_key, frame_key='t', _edge_indicator_costs = get_default_edge_indicator_costs( config, parameters) else: - _node_indicator_costs = node_indicator_costs(config, parameters, + _node_indicator_costs = node_indicator_costs(parameters, track_graph) - _edge_indicator_costs = edge_indicator_costs(config, parameters, + _edge_indicator_costs = edge_indicator_costs(parameters, track_graph) if not solver: From 5e12cf1f880ee37c28acbccde3c9aa2ff60ca52a Mon Sep 17 00:00:00 2001 From: Peter Hirsch Date: Fri, 22 Jul 2022 16:46:54 -0400 Subject: [PATCH 217/263] start extending readme --- README | 44 -------------------------------- README.assets/pipeline.png | Bin 0 -> 1227396 bytes README.md | 50 +++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 44 deletions(-) delete mode 100644 README create mode 100644 README.assets/pipeline.png create mode 100644 README.md diff --git a/README b/README deleted file mode 100644 index fd769ab..0000000 --- a/README +++ /dev/null @@ -1,44 +0,0 @@ -README: Linajea - -NOTE: -We are in the process of refactoring the code and adding more examples. -You can find the current (experimental) state here: https://github.com/Kainmueller-Lab/linajea/tree/1.5-dev -We will merge it in the next few days. - -This is the main software repository for the linajea cell tracking project. -Includes tools and infrastructure for running a pipeline that starts from light -sheet data and ends in extracted cell lineage tracks. The linajea_experiments -repository (https://github.com/funkelab/linajea_experiments) contains example -scripts, network setups, and data folders, and is the location to -actually run training and inference scripts. This library contains all the -supporting code to run those experiments, and is where most of the interesting -functionality resides, other than network architecture definition. - - -INSTALLATION - - create a conda environment - - pip or conda install numpy, cython - - pip install -r requirements.txt - - conda install -c funkey pylp - - pip install . - - -VERSIONING -The current stable version is v1.3, and the 1.4-dev branch has the -most cutting edge functionality. We will do our best to release minor version -updates with bugfixes and added functionality, while major -version updates will be reserved for changes that break backwards -compatibility. - - -CONTRIBUTING -If you make any improvements to the software, please fork, create a new branch named -descriptively for the feature you are upgrading or bug you are fixing, commit -your changes to that branch, and create a pull request asking for -permission to merge. Help is always appreciated! - - -OTHER -Many of the modules in this project have their own README to provide more information. -If you can't find the answers you need there or in the code documentation, feel free -to contact me! diff --git a/README.assets/pipeline.png b/README.assets/pipeline.png new file mode 100644 index 0000000000000000000000000000000000000000..c8ea9255165eecf2c80fb4eb1e19982104496e3d GIT binary patch literal 1227396 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%0AdQwUJD*A?Qe26pPiuwTC&u2jKRP+u*pX% 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ⓈesQdvIT6u^ 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ⅅ`gcdT`*KG`Ld( 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ÉvZ57J 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
  • DyTt_8!2%ReYqQu!}5OPXi-k+FAxm(hz6x4iNAM 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

    uMr>a|xoCVR@{sjATv+_cxJ5gny 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

      nXSaq~`loMTZr;3y7H)FKVajFe06V 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 rxk5FB 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