Commit 94badcb2 authored by Timm Schoening's avatar Timm Schoening
Browse files

massive overhaul of mariqt older structure to become more generic. includes...

massive overhaul of mariqt older structure to become more generic. includes renaming of files and classes.
parent de699765
......@@ -3,3 +3,11 @@
# MarIQT
Image Quality control / quality assurance and curation Tools (IQT) conceptualised and developed by the MareHub working group on Videos/Images (part of the DataHub, a research data manangement initiative by the Helmholtz association). The MarIQT core is a [python package](https://gitlab.hzdr.de/datahub/marehub/ag-videosimages/software/mar-iqt/-/tree/master/mariqt) which is helpful on its own but is key to the more user-friendly [jupyter notebooks](https://gitlab.hzdr.de/datahub/marehub/ag-videosimages/software/mar-iqt/-/tree/master/jupyter) that make extensive use of the python package.
# Get started
1. Check-out this repository
2. Run the notebook 0_Initialize and populate it with your project's details to create a curation folder and curation settings file
3. Switch to your new project curation folder (its also a Git repo by now)
4. Run the notebook 1_CurationOverview now (and whenever you need an update) to see which steps still lie ahead of you
5. Run other notebooks to conduct the data curation steps. An overview of their functionality is given in the notebook folder's README.me
data:
base_paths: [/volumes/project/]
base_paths_remote: [/volumes/project/]
use_gear_folders: false
equipment:
CAM:
- {eqid: ADD CAM EQUIPMENT HERE}
PFM:
- {eqid: ADD PFM EQUIPMENT HERE}
images:
artist: Holothurian Impact team
copyright: '(c) International Ocean Research. Contact: press@foobar.de'
credit: Holothurian Impact & Dr. Jane Doe
description: 'Acquired by camera ___DEPLOYMENT:CAMERAID___ mounted on platform ___DEPLOYMENT:PLATFORM___
during cruise ___CRUISE:NUMBER___ (station: ___DEPLOYMENT:STATION___). Navigation
data were automatically edited by the MarIQT software (removal of outliers, smoothed
and splined to fill time gaps) and linked to the image data by timestamp.'
editor: John Doe
license: CC-BY
pfdo: {acquisition: photo, deployment: survey, illumination: artificial light, image-quality: raw,
navigation: beacon, resolution: mm, scale-reference: laser marker, spectral-resolution: rgb,
zone: seafloor}
navigation_data:
processing_parameters:
DEFAULT:
- {name: source, value: DSHIP}
- {name: beacon_id, value: 2}
- {name: max_vertical_speed, unit: m/s, value: 3.0}
- {name: max_lateral_speed, unit: m/s, value: 2.0}
- {name: max_time_gap, unit: s, value: 300}
- {name: smoothing_gauss_half_width, unit: s, value: 60}
- {name: outlier_check_min_neighbors, unit: number, value: 5}
- {name: max_allowed_outlier_lateral_dist, unit: m, value: 10}
- {name: max_allowed_outlier_vertical_dist, unit: m, value: 10}
- {name: outlier_check_time_window_size, unit: s, value: 60}
MUC:
- {name: processing_type, value: station}
- {name: beacon_id, value: 1}
ROV:
- {name: processing_type, value: transect}
- {name: beacon_id, value: 4}
sources:
DSHIP:
dship_all_device_operations_file: /Users/tschoening/dev/repos/mariqt-test/files/PRJ23_all-device-operations.dat
dship_all_underwater_navigation_file: /Users/tschoening/dev/repos/mariqt-test/files/PRJ23_all-underwater-navigation.dat
data_frequency_seconds: 5
date_format: '%Y/%m/%d %H:%M:%S'
dship_event_navigation_folder: /Users/tschoening/dev/repos/mariqt-test/files/dship_zips/
dship_user_mail: jdoe1@foobar.de
dship_user_name: JohnDoe
max_depth: 6000
satellite_navigation: {sensor_equipment_id: ADD_EQUIPMENT_ID_HERE}
underwater_navigation: {sensor_equipment_id: ADD_EQUIPMENT_ID_HERE}
FIXED: {latitude: 0.0, longitude: 0.0}
project:
acronym: Holothurian Impact
copyright: '(c) International Ocean Research. Contact: press@foobar.de'
data-pi: {affiliation: International Ocean Research, email: jdoe1@foobar.de, name: John
Doe, orcid: 9876-5432-1000-0000}
end: '2020-05-27 08:00:00'
funding: Funding for this project was provided by the International Funding Agency
(1234ABCD987)
info: {de: Ein deutscher Text mit ca. 1000 Zeichen der das Projekt beschreibt.,
en: 'An english text of ca. 1000 characters length, describing the project.'}
license: CC-BY
number: PRJ23
pi: {affiliation: International Ocean Research, email: jdoe@foobar.de, name: Dr.
Jane Doe, orcid: 0000-0001-2345-6789}
start: '2019-02-15 06:00:00'
title: Assessing the impacts of holothurian harvesting.
""" This class implements the MareHub AGVI folder structure convention. this convention specifies that data should be structured like so: /base/project/[Gear/]event/sensor/data_type/ E.g.: /mnt/nfs/cruises/SO268/SO268-1_021-1_OFOS-02/SONNE_CAM-01_OFOS-Still/raw/"""
""" This class implements the MareHub AGVI directory structure convention. this convention specifies that data should be structured like so: /base/project/[Gear/]event/sensor/data_type/ E.g.: /mnt/nfs/cruises/SO268/SO268-1_021-1_OFOS-02/SONNE_CAM-01_OFOS-Still/raw/"""
import os
from enum import Enum
import mariqt.core as miqtc
class Path:
class Dir:
class dt(Enum):
""" The terminal data folders allowed at the end of path"""
""" The terminal data folders allowed at the end of directory path"""
external = 0
raw = 1
intermediate = 2
......@@ -15,32 +15,32 @@ class Path:
products = 4
protocol = 5
class dp(Enum):
""" The five folders that make up the valid paths according to the heuristic (GEAR is optional)"""
PRJ = 0
GEAR = 1
EVENT = 2
SENSOR = 3
TYPE = 4
def __init__(self,base_path:str,path:str,create:bool = False,with_gear:bool = False):
""" Requires the basepath (e.g. /mnt/nfs/cruises/) and the subsequent parts of the path (e.g. SO268/SO268-1_021-1_OFOS-02/SONNE_CAM-01_OFOS-Still/raw/). Will create a missing path if asked to do so."""
base_path = miqtc.assertSlash(base_path.replace("//","/"))
path = miqtc.assertSlash(path.replace("//","/"))
if path[0] == "/":
path = path[1:]
self.base_path = base_path
""" The five folders that make up the valid directory paths according to the convention (GEAR is optional)"""
GEAR = 0
EVENT = 1
SENSOR = 2
TYPE = 3
def __init__(self,base_dir:str,dir:str,create:bool = False,with_gear:bool = False):
""" Requires the base_dir (e.g. /mnt/nfs/cruises/SO268/) and the subsequent parts of the directory path (e.g. SO268-1_021-1_OFOS-02/SONNE_CAM-01_OFOS-Still/raw/). Will create a missing directory path if asked to do so."""
base_dir = miqtc.assertSlash(base_dir.replace("//","/"))
dir = miqtc.assertSlash(dir.replace("//","/"))
if dir[0] == "/":
dir = dir[1:]
self.base_dir = base_dir
self.with_gear = with_gear
self.dirs = {self.dp.PRJ:"",self.dp.GEAR:"",self.dp.EVENT:"",self.dp.SENSOR:"",self.dp.TYPE:""}
self.keys = {self.dp.PRJ:False,self.dp.GEAR:False,self.dp.EVENT:False,self.dp.SENSOR:False,self.dp.TYPE:False}
self.dirs = {self.dp.GEAR:"",self.dp.EVENT:"",self.dp.SENSOR:"",self.dp.TYPE:""}
self.keys = {self.dp.GEAR:False,self.dp.EVENT:False,self.dp.SENSOR:False,self.dp.TYPE:False}
base_len = len(base_path.split("/"))
tmp = path.split("/")
base_len = len(base_dir.split("/"))
tmp = dir.split("/")
idx = 0
for d in self.dirs:
# Do not try to add more parts of the path than available
# Do not try to add more parts of the directory path than available
if idx >= len(tmp) or tmp[idx] == "":
break
......@@ -48,7 +48,7 @@ class Path:
if d == self.dp.GEAR and not self.with_gear:
continue
# For the final (type) part of the path, check whether the given value is valid
# For the final (type) part of the directory path, check whether the given value is valid
if d == self.dp.TYPE:
valid = False
for dtt in self.dt:
......@@ -62,17 +62,17 @@ class Path:
idx += 1
if create:
self.create(self.str())
self.create()
def dump(self,):
""" Dumps the path to the console, mainly for debugging. Use str() instead to get a proper path string"""
print("Basepath:",self.base_path,"Path:",self.dirs)
""" Dumps the directory path to the console, mainly for debugging. Use str() instead to get a proper directory string"""
print("Basedir:",self.base_dir,"Dirs:",self.dirs)
def str(self):
""" Turns the Path object information into a path string. Returns everything of the path that is known."""
ret = self.base_path
""" Turns the Dir object information into a directory string. Returns everything of the directory path that is known."""
ret = self.base_dir
for d in self.dirs:
if d == self.dp.GEAR and not self.with_gear:
continue
......@@ -82,8 +82,8 @@ class Path:
return ret
def validDataPath(self):
""" Validates whether path information is available until the data_type"""
def validDataDir(self):
""" Validates whether directory information is available until the data_type"""
for d in self.dirs:
if d == self.dp.GEAR and not self.with_gear:
continue
......@@ -93,19 +93,19 @@ class Path:
def exists(self):
""" Checks whether the path of this object exists"""
""" Checks whether the directory of this object exists"""
return os.path.exists(self.str())
def create(self):
""" Creates the path of this object if it does not exist"""
""" Creates the directory of this object if it does not exist"""
if not self.exists():
os.mkdir(self.str(),0o755)
def replace(self,dir:dp,new_val:str,keep_rest:bool = False):
""" Can replace one (!) part of the objects path (given by the keyword in dir) and replaces it with the value in new_val. Useful to change e.g. from one sensor to another or from one event to another. This only returns a string! In case you want to have another Path object, use replaceCreatePath() instead."""
tmp_path = ""
""" Can replace one (!) part of the object's directory (given by the keyword in dir) and replaces it with the value in new_val. Useful to change e.g. from one sensor to another or from one event to another. This only returns a string! In case you want to have another Dir object, use replaceCreateDir() instead."""
tmp_dir = ""
for d in self.dirs:
if d == dir or d.name == dir:
......@@ -119,24 +119,24 @@ class Path:
if not valid:
raise ValueError(new_val + " is not a valid data type")
tmp_path += new_val + "/"
tmp_dir += new_val + "/"
if keep_rest == False:
break
else:
if d == self.dp.GEAR and not self.with_gear:
continue
tmp_path += self.dirs[d] + "/"
return self.base_path + tmp_path
tmp_dir += self.dirs[d] + "/"
return self.base_dir + tmp_dir
def replaceCreatePath(self,dir:dp,new_val:str,keep_rest:bool = False):
""" Can replace one (!) part of the objects path (given by the keyword in dir) and replaces it with the value in new_val. Useful to change e.g. from one sensor to another or from one event to another. This returns a new Path object! In case you only want to have a string, use replace() instead."""
def replaceCreateDir(self,dir:dp,new_val:str,keep_rest:bool = False):
""" Can replace one (!) part of the object's directory (given by the keyword in dir) and replaces it with the value in new_val. Useful to change e.g. from one sensor to another or from one event to another. This returns a new Dir object! In case you only want to have a string, use replace() instead."""
ret = self.replace(dir,new_val,keep_rest)
return Path(self.base_path,ret.replace(self.base_path,""),with_gear = self.with_gear)
return Dir(self.base_dir,ret.replace(self.base_dir,""),with_gear = self.with_gear)
""" Getter for the base, type, sensor, event, gear, prj"""
""" Getter for the base, type, sensor, event, gear"""
def base(self):
return self.base_path
return self.base_dir
def type(self):
return self.dirs[self.dp.TYPE]
def sensor(self):
......@@ -145,5 +145,12 @@ class Path:
return self.dirs[self.dp.EVENT]
def gear(self):
return self.dirs[self.dp.GEAR]
def prj(self):
return self.dirs[self.dp.PRJ]
def togear(self):
return self.replace(self,self.dp.GEAR,self.gear())
def toevent(self):
return self.replace(self,self.dp.EVENT,self.event())
def tosensor(self):
return self.replace(self,self.dp.SENSOR,self.sensor())
def totype(self):
return self.replace(self,self.dp.TYPE,self.type())
......@@ -54,89 +54,32 @@ def recursiveFileStat(path,ext = False):
return num,size
def cfgFileLoad(path:str,ft:str = "guess"):
""" Tries to read a json or yaml file from disk and returns the entire content as a dict"""
miqtc.assertExists(path)
if ft == "guess":
file_name, ft = os.path.splitext(path.lower())
if ft == ".json":
with open(path,encoding='utf8',errors='ignore') as f:
return json.load(f)
elif ft == ".yaml":
with open(path,encoding='utf8',errors='ignore') as f:
return yaml.safe_load(f)
else:
raise ValueError("Could not find config file parser for type "+ ft)
def cfgFileLoadProjectDefault(project:str = ""):
""" Expects a file called <project>_curation-settings.yaml in a folder ../files/ relative to the current work directory, loads it and returs the content"""
path = "../files/"
if not os.path.exists(path):
raise ValueError("Path for config file not found:",path)
files = os.listdir(path)
found = []
for file in files:
if project != "" and file == project+"_curation-settings.yaml":
found.append(file)
break
elif project == "" and "_curation-settings.yaml" in file:
found.append(file)
if len(found) == 0:
raise ValueError("No config file found in:",path)
elif len(found) > 1:
raise ValueError("More than one config file found in:",path)
else:
return cfgFileLoad(path+found[0],ft=".yaml")
def cfgValueGet(cfg, key:str):
""" Tries to return a value from a configuration file
Nice thing is it employs a URN like syntax so to descend into the configuration file content use something like cfgValue(cfg,'key-a:key-1:key-N')
"""
keys = key.split(":")
ar = cfg
for k in keys:
if not k in ar:
raise ValueError("Could not find key " + key + " in config values")
ar = ar[k]
return ar
def cfgValue(cfg, key:str):
""" Tries to parse a value from a configuration file meaning that placeholder variables (___<var>___) are replaced by available content"""
val = cfgValueGet(cfg, key)
if isinstance(val,dict) or isinstance(val,list):
return val
vals = val.split("___")
for i in range(1,len(vals),2):
val = val.replace("___"+vals[i]+"___",cfgValueGet(cfg, vals[i].lower()))
return val
def tabFileColumnIndex(col_name,cols):
""" Takes an array (a data header row) of column names and returns the index of one given column that is searched for. Returns -1 if said column name does not exist."""
search_prefix = False
search_suffix = False
if col_name[-1] == "*":
search_prefix = True
col_name = col_name[0:len(col_name)-2]
elif col_name[0] == "*":
search_suffix = True
col_name = col_name[1:]
for k in range(len(cols)):
if search_prefix and cols[k][0:len(col_name)] == col_name:
return k
elif search_suffix and cols[k][-len(col_name):] == col_name:
return k
elif cols[k] == col_name:
return k
return -1
def tabFileColumnIndices(col_names,cols,optional=[]):
""" Receives a list of header field names and a list of required column names and returns the column indices of those colums."""
""" Receives a list of header field names and a list of required column names and returns the column indices of those colums.
The column names given in the optional parameter also have to be part of the required_names!
"""
col_indcs = {}
for name in col_names:
......@@ -202,7 +145,8 @@ def tabFileColumnIndicesFromFile(file,required_names,col_separator="\t",optional
""" Extracts column indices for requested column names from a file.
Can handle either an open file object or a path to a file as the first argument
Read from the file until a row is found that is not a comment
Read from the file until a row is found that is not a comment.
The column names given in the optional parameter also have to be part of the required_names!
"""
if isinstance(file, str):
......@@ -219,15 +163,16 @@ def tabFileColumnIndicesFromFile(file,required_names,col_separator="\t",optional
return max_col_idx,col_indcs
def tabFileData(path,required_names,col_separator="\t",key_col="",graceful=False):
def tabFileData(path,required_names,col_separator="\t",key_col="",graceful=False,optional=[]):
""" Read all data values from a file for a given yet of column names.
Returns a dictionary of lines, containing dictionaries with column
names as keys and extracted data from the file as values
names as keys and extracted data from the file as values.
The column names given in the optional parameter also have to be part of the required_names!
"""
file = open(path,"r",errors="ignore",encoding="utf-8")
max_col_idx,col_indcs = tabFileColumnIndicesFromFile(file,required_names,col_separator)
max_col_idx,col_indcs = tabFileColumnIndicesFromFile(file,required_names,col_separator,optional)
if key_col != "" and col_indcs[key_col] >= 0:
data = {}
for line in file:
......
import mariqt.core as miqtc
import mariqt.definitions as miqtd
class Position:
""" A class defining a 4.5D position, encoded by a utc time, latitude, longitude, depth and height. Depth and heigt are optional."""
......
......@@ -4,10 +4,11 @@ import datetime
import subprocess
import mariqt.core as miqtc
import mariqt.paths as miqtp
import mariqt.validation as miqtv
import mariqt.definitions as miqtd
import mariqt.processing.files as miqtpf
import mariqt.directories as miqtd
import mariqt.tests as miqtt
import mariqt.variables as miqtv
import mariqt.files as miqtf
import mariqt.settings as miqts
def getVideoRuntime(path):
......@@ -60,7 +61,7 @@ def getImageUUIDsForFolder(path):
return ret
def createiFDOHeader(cfg:dict,overload:dict,path:miqtp.Path,handle_prefix:str):
def collectiFDOHeader(cfg:miqts.Settings,overload:dict,dir:miqtd.Dir,handle_prefix:str):
""" Creates an iFDO header from a config file dict and optional overload parameters.
Takes a config value dictionary and extracts all default values from it. These default values can be overloaded by providing another (flat) dictionary with iFDO parameters in it. The project, event and sensor information are extracted from a path object."""
......@@ -71,45 +72,45 @@ def createiFDOHeader(cfg:dict,overload:dict,path:miqtp.Path,handle_prefix:str):
print("No value given for image-set-coordinate-uncertainty")
return {}
if path.sensor() == "":
print("Path given does not contain sensor information",path.str())
if dir.sensor() == "":
print("Path given does not contain sensor information",dir.str())
return {}
if not 'image-set-project' in overload:
ret['image-set-project'] = path.prj()
if not 'image-set-event' in overload:
ret['image-set-event'] = path.event()
ret['image-set-event'] = dir.event()
if not 'image-set-sensor' in overload:
ret['image-set-sensor'] = path.sensor()
ret['image-set-sensor'] = dir.sensor()
if not 'image-set-name' in overload:
ret['image-set-name'] = path.prj() + " " + path.event() + " " + path.sensor()
ret['image-set-name'] = cfg["project:number"] + " " + dir.event() + " " + dir.sensor()
if not 'image-set-project' in overload:
ret['image-set-project'] = cfg["project:number"]
if not 'image-set-abstract' in overload:
ret['image-set-abstract'] = miqtpf.cfgValue(cfg,"images:ifdo:DEFAULT:image-set-abstract")
ret['image-set-abstract'] = cfg["images:ifdo:DEFAULT:image-set-abstract"]
if not 'image-set-creators' in overload:
ret['image-set-creators'] = miqtpf.cfgValue(cfg,"images:ifdo:DEFAULT:image-set-creators")
ret['image-set-creators'] = cfg["images:ifdo:DEFAULT:image-set-creators"]
if not 'image-set-pi' in overload:
ret['image-set-pi'] = miqtpf.cfgValue(cfg,"images:ifdo:DEFAULT:image-set-pi")
ret['image-set-pi'] = cfg["images:ifdo:DEFAULT:image-set-pi"]
if not 'image-set-license' in overload:
ret['image-set-license'] = miqtpf.cfgValue(cfg,"images:ifdo:DEFAULT:image-set-license")
ret['image-set-license'] = cfg["images:ifdo:DEFAULT:image-set-license"]
if not 'image-set-copyright' in overload:
ret['image-set-copyright'] = miqtpf.cfgValue(cfg,"images:ifdo:DEFAULT:image-set-copyright")
ret['image-set-copyright'] = cfg["images:ifdo:DEFAULT:image-set-copyright"]
if not 'image-set-crs' in overload:
ret['image-set-crs'] = miqtpf.cfgValue(cfg,"images:ifdo:DEFAULT:image-set-crs")
ret['image-set-crs'] = cfg["images:ifdo:DEFAULT:image-set-crs"]
if not 'image-set-context' in overload:
ret['image-set-context'] = miqtpf.cfgValue(cfg,"project:context:number")
ret['image-set-context'] = cfg["project:context:number"]
if not 'image-set-type' in overload:
ret['image-set-type'] = miqtpf.cfgValue(cfg,"images:pfdo:DEFAULT:acquisition")
ret['image-set-type'] = cfg["images:pfdo:DEFAULT:acquisition"]
if not 'image-set-platform' in overload:
cams = miqtpf.cfgValue(cfg,"equipment:CAM")
cams = cfg["equipment:CAM"]
for cam in cams:
if cam['eqid'] == path.sensor():
if cam['eqid'] == dir.sensor():
ret['image-set-platform'] = cam['pfm-eqid']
break
if not 'image-set-platform' in ret:
print("No platform found for camera",path.sensor())
print("No platform found for camera",dir.sensor())
return {}
if not 'image-set-uuid' in overload:
......@@ -124,86 +125,183 @@ def createiFDOHeader(cfg:dict,overload:dict,path:miqtp.Path,handle_prefix:str):
for o in overload:
ret[o] = overload[o]
if len(ret['image-set-abstract']) < 100 or len(ret['image-set-abstract']) > 1000:
print("Length of the abstract is too long or too short")
return {}
if not miqtv.isValidPerson(ret['image-set-pi']):
print("Not a valid person description for the pi")
return {}
for p in ret['image-set-creators']:
if not miqtv.isValidPerson(p):
print("Not a valid person description for one of the creators",p)
return {}
return ret
def createiFDO(path,header,items):
def collectiFDOItems(dir:miqtd.Dir):
""" Collect all item information from a sensor folder to fill the iFDO image item section"""
req = {'hashes':{'suffix':'_image-hashes.txt','cols':['image-hash'],'optional':[]},
'navigation':{'suffix':'_image-navigation.txt','cols':['image-longitude','image-latitude'],'optional':['image-depth','image-altitude','image-second']},
'scaling':{'suffix':'_image-scaling.txt','cols':['image-pixel-per-millimeter'],'optional':[]},
'uuids':{'suffix':'_image-uuids.txt','cols':['image-uuid'],'optional':[]},
'datetime':{'suffix':'_image-start-times.txt','cols':['image-datetime'],'optional':[]}}
int_folder = dir.replace(dir.dp.TYPE,"intermediate")
item_data = {}
for r in req:
if not os.path.exists(int_folder + dir.event() + "_" + dir.sensor() + req[r]['suffix']):
raise Exception("Image item file is missing:",int_folder + dir.event() + "_" + dir.sensor() + req[r]['suffix'])
tmp_data = miqtf.tabFileData(int_folder + dir.event() + "_" + dir.sensor() + req[r]['suffix'],req[r]['cols']+['image-filename']+req[r]['optional'],key_col='image-filename',optional=req[r]['optional'])
for img in tmp_data:
if img not in item_data:
item_data[img] = {}
for v in tmp_data[img]:
item_data[img][v] = tmp_data[img][v]
item_data[img]['image-filename'] = img
prev_len = -1
for item in item_data:
if prev_len > 0 and len(item) != prev_len:
raise Exception("Not all items have the same amount of metadata",len(item),prev_len)
return item_data.values()
def validateiFDOField(field,value):
if field in ['image-filename']:
if not miqtt.isValidImageName(value):
raise Exception('Invalid item name',value)
elif field in ['image-datetime']:
try:
datetime.datetime.strptime(value,'%Y-%m-%d %H:%M:%S.%f')
except:
raise Exception('Invalid datetime value',value)
elif field in ['image-longitude','image-set-longitude']:
try:
value = float(value)
except:
raise Exception(field,'value is not a float',value)
if value < -180 or value > 180:
raise Exception(field,'value is out of bounds',value)
elif field in ['image-latitude','image-set-latitude']:
try:
value = float(value)
except:
raise Exception(field,'value is not a float',value)
if value < -90 or value > 90:
raise Exception(field,'value is out of bounds',value)
elif field in ['image-depth','image-set-depth']:
try:
value = float(value)
except:
raise Exception(field,'value is not a float',value)
if value < 0:
raise Exception(field,'value is out of bounds',value)
elif field in ['image-altitude','image-set-altitude']:
try:
value = float(value)
except:
raise Exception(field,'value is not a float',value)
if value < 0:
raise Exception(field,'value is out of bounds',value)
elif field in ['image-set-abstract']:
if len(value) < 100 or len(value) > 1000:
raise Exception("Length of the abstract is too long or too short")
elif field in ['image-pi','image-set-pi']:
if not miqtt.isValidPerson(value):
raise Exception("Not a valid person description for the pi")
elif field in ['image-set-creators']:
for p in value: