PyOPIA 2.2.0__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyOPIA
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: A Python Ocean Particle Image Analysis toolbox.
5
5
  Home-page: https://github.com/sintef/pyopia
6
6
  Keywords: Ocean,Particles,Imaging,Measurement,Size distribution
@@ -0,0 +1 @@
1
+ __version__ = '2.4.0'
@@ -4,17 +4,19 @@ PyOPIA top-level code primarily for managing cmd line entry points
4
4
 
5
5
  import typer
6
6
  import toml
7
- from glob import glob
8
7
  import os
9
8
  import datetime
10
9
  import traceback
11
10
  import logging
12
11
  from rich.progress import track, Progress
12
+ from rich.logging import RichHandler
13
13
  import pandas as pd
14
+ import threading
14
15
 
15
16
  import pyopia.background
16
17
  import pyopia.instrument.silcam
17
18
  import pyopia.instrument.holo
19
+ import pyopia.instrument.uvp
18
20
  import pyopia.instrument.common
19
21
  import pyopia.io
20
22
  import pyopia.pipeline
@@ -80,7 +82,7 @@ def generate_config(instrument: str, raw_files: str, model_path: str, outfolder:
80
82
  Parameters
81
83
  ----------
82
84
  instrument : str
83
- either `silcam` or `holo`
85
+ either `silcam`, `holo` or `uvp`
84
86
  raw_files : str
85
87
  raw_files
86
88
  model_path : str
@@ -95,6 +97,8 @@ def generate_config(instrument: str, raw_files: str, model_path: str, outfolder:
95
97
  pipeline_config = pyopia.instrument.silcam.generate_config(raw_files, model_path, outfolder, output_prefix)
96
98
  case 'holo':
97
99
  pipeline_config = pyopia.instrument.holo.generate_config(raw_files, model_path, outfolder, output_prefix)
100
+ case 'uvp':
101
+ pipeline_config = pyopia.instrument.uvp.generate_config(raw_files, model_path, outfolder, output_prefix)
98
102
 
99
103
  config_filename = instrument + "-config.toml"
100
104
  with open(config_filename, "w") as toml_file:
@@ -102,23 +106,35 @@ def generate_config(instrument: str, raw_files: str, model_path: str, outfolder:
102
106
 
103
107
 
104
108
  @app.command()
105
- def process(config_filename: str):
109
+ def process(config_filename: str, num_chunks: int = 1):
106
110
  '''Run a PyOPIA processing pipeline based on given a config.toml
111
+
112
+ Parameters
113
+ ----------
114
+ config_filename : str
115
+ config filename
116
+ numchunks : int, optional
117
+ split the dataset into chucks, and process in parallell, by default 1
118
+
107
119
  '''
108
120
  from pyopia.io import load_toml
109
121
  from pyopia.pipeline import Pipeline
110
122
 
111
- logger = logging.getLogger()
112
-
113
123
  with Progress(transient=True) as progress:
114
124
  progress.console.print("[blue]LOAD CONFIG")
115
125
  pipeline_config = load_toml(config_filename)
116
126
 
117
127
  setup_logging(pipeline_config)
128
+ logger = logging.getLogger('rich')
129
+ logger.info(f'PyOPIA process started {pd.Timestamp.now()}')
130
+
131
+ check_chunks(num_chunks, pipeline_config)
118
132
 
119
133
  progress.console.print("[blue]OBTAIN FILE LIST")
120
- files = sorted(glob(pipeline_config['general']['raw_files']))
121
- nfiles = len(files)
134
+ raw_files = pyopia.pipeline.FilesToProcess(pipeline_config['general']['raw_files'])
135
+ average_window = pipeline_config['steps']['correctbackground'].get('average_window', 0)
136
+ bgshift_function = pipeline_config['steps']['correctbackground'].get('bgshift_function', 'pass')
137
+ raw_files.prepare_chunking(num_chunks, average_window, bgshift_function)
122
138
 
123
139
  progress.console.print('[blue]PREPARE FOLDERS')
124
140
  if 'output' not in pipeline_config['steps']:
@@ -131,20 +147,32 @@ def process(config_filename: str):
131
147
  if os.path.isfile(output_datafile + '-STATS.nc'):
132
148
  dt_now = datetime.datetime.now().strftime('D%Y%m%dT%H%M%S')
133
149
  newname = output_datafile + '-conflict-' + str(dt_now) + '-STATS.nc'
134
- progress.console.print('[red]Renaming conflicting file to: ' +
135
- newname)
150
+ logger.warning(f'Renaming conflicting file to: {newname}')
136
151
  os.rename(output_datafile + '-STATS.nc', newname)
137
152
 
138
153
  progress.console.print("[blue]INITIALISE PIPELINE")
139
- processing_pipeline = Pipeline(pipeline_config)
140
154
 
141
- for filename in track(files, description=f'[blue]Processing progress through {nfiles} files:'):
142
- try:
143
- processing_pipeline.run(filename)
144
- except Exception as e:
145
- progress.console.print("[red]An error occured in processing, skipping rest of pipeline and moving to next image.")
146
- logger.error(e)
147
- logger.debug(''.join(traceback.format_tb(e.__traceback__)))
155
+ def process_file_list(file_list, c):
156
+ processing_pipeline = Pipeline(pipeline_config)
157
+ for filename in track(file_list, description=f'[blue]Processing progress (chunk {c})',
158
+ disable=c != 0):
159
+ try:
160
+ logger.debug(f'Chunk {c} starting to process {filename}')
161
+ processing_pipeline.run(filename)
162
+ except Exception as e:
163
+ logger.warning('[red]An error occured in processing, ' +
164
+ 'skipping rest of pipeline and moving to next image.' +
165
+ f'(chunk {c})')
166
+ logger.error(e)
167
+ logger.debug(''.join(traceback.format_tb(e.__traceback__)))
168
+
169
+ # With one chunk we keep the non-threaded functionality to ensure backwards compatibility
170
+ if num_chunks == 1:
171
+ process_file_list(raw_files, 0)
172
+ else:
173
+ for c, chunk in enumerate(raw_files.chunked_files):
174
+ job = threading.Thread(target=process_file_list, args=(chunk, c, ))
175
+ job.start()
148
176
 
149
177
 
150
178
  @app.command()
@@ -176,15 +204,25 @@ def setup_logging(pipeline_config):
176
204
  log_file = pipeline_config['general'].get('log_file', None)
177
205
  log_level_name = pipeline_config['general'].get('log_level', 'INFO')
178
206
  log_level = getattr(logging, log_level_name)
179
- print(log_level_name, log_level, log_file)
207
+
208
+ # Either log to file (silent console) or to console with Rich
209
+ if log_file is None:
210
+ handlers = [RichHandler(show_time=True, show_level=False)]
211
+ else:
212
+ handlers = [logging.FileHandler(log_file, mode='a')]
180
213
 
181
214
  # Configure logger
182
- log_format = '%(asctime)s %(levelname)s [%(module)s.%(funcName)s] %(message)s'
183
- logging.basicConfig(level=log_level, format=log_format, filename=log_file,
184
- datefmt='%Y-%m-%d %H:%M:%S')
215
+ log_format = '%(asctime)s %(levelname)s %(threadName)s [%(module)s.%(funcName)s] %(message)s'
216
+ logging.basicConfig(level=log_level, datefmt='%Y-%m-%d %H:%M:%S', format=log_format, handlers=handlers)
217
+
218
+
219
+ def check_chunks(chunks, pipeline_config):
220
+ if chunks < 1:
221
+ raise RuntimeError('You must have at least 1 chunk')
185
222
 
186
- logger = logging.getLogger()
187
- logger.info(f'PyOPIA process started {pd.Timestamp.now()}')
223
+ append_enabled = pipeline_config['steps']['output'].get('append', True)
224
+ if chunks > 1 and append_enabled:
225
+ raise RuntimeError('Output mode must be set to "append = false" in "output" step when using more than one chunk')
188
226
 
189
227
 
190
228
  if __name__ == "__main__":
@@ -34,9 +34,9 @@ def load_image(filename):
34
34
  Returns
35
35
  -------
36
36
  array
37
- raw image
37
+ raw image float between 0-1
38
38
  '''
39
- img = np.load(filename, allow_pickle=False).astype(np.float64)
39
+ img = np.load(filename, allow_pickle=False).astype(np.float64) / 255
40
40
  return img
41
41
 
42
42
 
@@ -68,7 +68,7 @@ class SilCamLoad():
68
68
 
69
69
  def __call__(self, data):
70
70
  timestamp = timestamp_from_filename(data['filename'])
71
- img = load_image(data['filename']).astype(np.float64)/255
71
+ img = load_image(data['filename'])
72
72
  data['timestamp'] = timestamp
73
73
  data['imraw'] = img
74
74
  return data
@@ -0,0 +1,127 @@
1
+ '''
2
+ Module containing UVP specific tools to enable compatability with the :mod:`pyopia.pipeline`
3
+ '''
4
+
5
+ import os
6
+ import numpy as np
7
+ import pandas as pd
8
+ import skimage.io
9
+
10
+
11
+ def timestamp_from_filename(filename):
12
+ '''get a pandas timestamp from a UVP vignette image filename
13
+
14
+ Args:
15
+ filename (string): UVP filename (.png)
16
+
17
+ Returns:
18
+ timestamp: timestamp from pandas.to_datetime()
19
+ '''
20
+
21
+ # get the timestamp of the image (in this case from the filename)
22
+ timestr = os.path.split(filename)[-1].strip('.png')
23
+ timestamp = pd.to_datetime(timestr)
24
+ return timestamp
25
+
26
+
27
+ def load_image(filename):
28
+ '''load a UVP .png file from disc
29
+
30
+ Parameters
31
+ ----------
32
+ filename : string
33
+ filename to load
34
+
35
+ Returns
36
+ -------
37
+ array
38
+ raw image float between 0-1, inverted so that particles are dark on a light background
39
+ '''
40
+ img_darkfield = skimage.io.imread(filename).astype(np.float64)
41
+ img_inverted = (255 - img_darkfield) / 255
42
+ return img_inverted
43
+
44
+
45
+ class UVPLoad():
46
+ '''PyOpia pipline-compatible class for loading a single UVP image
47
+ using :func:`pyopia.instrument.uvp.load_image`
48
+ and extracting the timestamp using
49
+ :func:`pyopia.instrument.uvp.timestamp_from_filename`
50
+
51
+ Pipeline input data:
52
+ ---------
53
+ :class:`pyopia.pipeline.Data`
54
+ containing the following keys:
55
+
56
+ :attr:`pyopia.pipeline.Data.filename`
57
+
58
+ Returns:
59
+ --------
60
+ :class:`pyopia.pipeline.Data`
61
+ containing the following new keys:
62
+
63
+ :attr:`pyopia.pipeline.Data.timestamp`
64
+
65
+ :attr:`pyopia.pipeline.Data.img`
66
+ '''
67
+
68
+ def __init__(self):
69
+ pass
70
+
71
+ def __call__(self, data):
72
+ timestamp = timestamp_from_filename(data['filename'])
73
+ img = load_image(data['filename'])
74
+ data['timestamp'] = timestamp
75
+ data['imraw'] = img
76
+ return data
77
+
78
+
79
+ def generate_config(raw_files: str, model_path: str, outfolder: str, output_prefix: str):
80
+ '''Generate example uvp config.toml as a dict
81
+
82
+ Parameters
83
+ ----------
84
+ raw_files : str
85
+ raw_files
86
+ model_path : str
87
+ model_path
88
+ outfolder : str
89
+ outfolder
90
+ output_prefix : str
91
+ output_prefix
92
+
93
+ Returns:
94
+ --------
95
+ dict
96
+ pipeline_config toml dict
97
+ '''
98
+ # define the configuration to use in the processing pipeline - given as a dictionary - with some values defined above
99
+ pipeline_config = {
100
+ 'general': {
101
+ 'raw_files': raw_files,
102
+ 'pixel_size': 80 # pixel size in um
103
+ },
104
+ 'steps': {
105
+ 'classifier': {
106
+ 'pipeline_class': 'pyopia.classify.Classify',
107
+ 'model_path': model_path
108
+ },
109
+ 'load': {
110
+ 'pipeline_class': 'pyopia.instrument.uvp.UVPLoad'
111
+ },
112
+ 'segmentation': {
113
+ 'pipeline_class': 'pyopia.process.Segment',
114
+ 'threshold': 0.95,
115
+ 'segment_source': 'imraw'
116
+ },
117
+ 'statextract': {
118
+ 'pipeline_class': 'pyopia.process.CalculateStats',
119
+ 'roi_source': 'imraw'
120
+ },
121
+ 'output': {
122
+ 'pipeline_class': 'pyopia.io.StatsH5',
123
+ 'output_datafile': os.path.join(outfolder, output_prefix)
124
+ }
125
+ }
126
+ }
127
+ return pipeline_config
@@ -10,6 +10,8 @@ from operator import methodcaller
10
10
  import sys
11
11
  from pyopia.io import steps_from_xstats as steps_from_xstats # noqa: E(F401)
12
12
  import logging
13
+ from glob import glob
14
+ import numpy as np
13
15
 
14
16
  logger = logging.getLogger()
15
17
 
@@ -322,3 +324,88 @@ def build_steps(toml_steps):
322
324
  steps[step_name] = build_repr(toml_steps, step_name)
323
325
 
324
326
  return steps
327
+
328
+
329
+ class FilesToProcess:
330
+ def __init__(self, glob_pattern=None):
331
+ '''Build file list from glob pattern if specified.
332
+ Create FilesToProcess.chunked_files is chunks specified
333
+ File list from glob will be sorted.
334
+
335
+ Parameters
336
+ ----------
337
+ glob_pattern : str, optional
338
+ Glob pattern, by default None
339
+ '''
340
+ self.files = None
341
+ self.background_files = []
342
+ self.chunked_files = []
343
+ if glob_pattern is not None:
344
+ self.files = sorted(glob(glob_pattern))
345
+
346
+ def from_filelist_file(self, path_to_filelist):
347
+ '''
348
+ Initialize explicit list of files to process from a text file.
349
+ The text file should contain one path to an image per line, which should be processed in order.
350
+ '''
351
+ with open(path_to_filelist, 'r') as fh:
352
+ self.files = list(fh.readlines())
353
+
354
+ def to_filelist_file(self, path_to_filelist):
355
+ '''Write file list to a txt file
356
+
357
+ Parameters
358
+ ----------
359
+ path_to_filelist : str
360
+ Path to txt file to write
361
+ '''
362
+ with open(path_to_filelist, 'w') as fh:
363
+ [fh.writelines(L + '\n') for L in self.files]
364
+
365
+ def prepare_chunking(self, num_chunks, average_window, bgshift_function):
366
+ if num_chunks > len(self.files) // 2:
367
+ raise RuntimeError('Number of chunks exceeds more than half the number of files to process. Use less chunks.')
368
+ self.chunk_files(num_chunks)
369
+ self.build_initial_background_files(average_window=average_window)
370
+ self.insert_bg_files_into_chunks(bgshift_function=bgshift_function)
371
+
372
+ def chunk_files(self, num_chunks: int):
373
+ '''Chunk the file list and create FilesToProcess.chunked_files
374
+
375
+ Parameters
376
+ ----------
377
+ chunks : int
378
+ number of chunks to produce (must be at least 1)
379
+ '''
380
+ if num_chunks < 1:
381
+ raise RuntimeError('You must have at least one chunk')
382
+ chunk_length = int(np.ceil(len(self.files) / num_chunks))
383
+ self.chunked_files = [self.files[i:i + chunk_length] for i in range(0, len(self.files), chunk_length)]
384
+
385
+ def insert_bg_files_into_chunks(self, bgshift_function='pass'):
386
+ average_window = len(self.background_files)
387
+ for i, chunk in enumerate(self.chunked_files):
388
+ if i > 0 and bgshift_function != 'pass':
389
+ # If the bgshift_function is not pass then we need to find a new set of
390
+ # background images for the start of next chunk. These will be the last
391
+ # average_window number of files from the previous chunk.
392
+ # If bgshift_function is 'pass', then we should use the same background files for all chunks
393
+ # so there is no need to extend the list of background files here
394
+ self.background_files.extend(self.chunked_files[i-1][-average_window:])
395
+ # we have to loop backwards over bg_files because we are inserting into the top of the chunk
396
+ chunk = [chunk.insert(0, bg_file) for bg_file in reversed(self.background_files[-average_window:])]
397
+
398
+ def build_initial_background_files(self, average_window=0):
399
+ '''
400
+ Create a list of files to use for initializing the background in the first chunk
401
+ '''
402
+ self.background_files = []
403
+ for f in self.files[0:average_window]:
404
+ self.background_files.append(f)
405
+
406
+ def __len__(self):
407
+ return len(self.files)
408
+
409
+ def __iter__(self):
410
+ for filename in self.files:
411
+ yield filename
@@ -342,7 +342,7 @@ def measure_particles(imbw, max_particles=5000):
342
342
  raise RuntimeError('Too many particles. Refer to documentation on max_particles parameter in measure_particles()')
343
343
  # @todo handle situation when too many particles are found
344
344
 
345
- region_properties = measure.regionprops(iml, cache=False)
345
+ region_properties = measure.regionprops(iml, cache=True)
346
346
 
347
347
  return region_properties
348
348
 
@@ -1 +0,0 @@
1
- __version__ = '2.2.0'
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes