#!/usr/bin/python """Tools to process data produced with Olympus FluoView.""" import xml.etree.ElementTree as etree from os import sep from os.path import basename, dirname, join from log import log import ConfigParser import codecs # TODO: a superclass for generic mosaic type experiments should be created as # it will also be required for other input formats, several methods should be # moved there (they are tagged with "move_to_superclass") class FluoViewMosaic(object): """Object representing a tiled ("mosaic") project from Olympus FluoView. Olympus FluoView creates a "MATL_Mosaic.log" file for each tiled project. The file contains XML (no specifications given), describing some generic settings like axis directions, number of mosaics, and some more. After the generic section, every mosaic (a set of tiles belonging together) is described in detail (number of tiles in x and y direction, overlap, stage positions, file names and positions of each of the mosaic's tiles). Please note that multiple mosaics are contained in these project files and each of the mosaics can have different properties. Example ------- >>> import volpy.fluoview as fv >>> from log import set_loglevel >>> set_loglevel(3) >>> mosaic = fv.FluoViewMosaic('TESTDATA/mosaic/MATL_Mosaic.log') >>> mosaic.experiment['mcount'] 1 >>> mosaic.experiment['xdir'] 'LeftToRight' >>> mosaic.mosaics[0]['tiles'][0]['imgf'] 'Slide1sec001\\\\Slide1sec001.oif' >>> mosaic.write_all_tile_configs() >>> code = mosaic.gen_stitching_macro_code('stitching') >>> mosaic.write_stitching_macro(code) """ def __init__(self, infile): """Parse all required values from the XML file. Instance Variables ------------------ infile : {'path': str, # path to input XML file 'dname': str, # the directory name (last part of 'path') 'fname': str # the input XML filename } tree : xml.etree.ElementTree experiment : {'mcount': int, # number of mosaics 'xdir': str, # X axis direction 'ydir': str # Y axis direction } mosaics : list of mosaics (dicts, see parse_mosaic) """ log.info('Reading FluoView Mosaic XML...') self.infile = {} self.infile['path'] = dirname(infile).replace('\\', sep) self.infile['dname'] = basename(dirname(self.infile['path'])) self.infile['fname'] = basename(infile) # a dictionary of experiment-wide settings self.experiment = {} # a list of dicts with mosaic specific settings self.mosaics = [] self.tree = etree.parse(infile) self.check_fvxml() self.parse_all_mosaics() log.info('Done.') def check_fvxml(self): """Check XML for being a valid FluoView mosaic experiment file. Evaluate the XML tree for known elements like the root tag (expected to be "XYStage", and some of the direct children to make sure the parsed file is in fact a FluoView mosaic XML file. Raises exceptions in case something expected can't be found in the tree. """ root = self.tree.getroot() if not root.tag == 'XYStage': raise TypeError('Unexpected value: %s' % root.tag) # the statements below raise an AttributeError if no such element was # found as a 'NoneType' is returned then: self.experiment['xdir'] = root.find('XAxisDirection').text self.experiment['ydir'] = root.find('YAxisDirection').text # FIXME: mcount is WRONG, it gives the index number of the last mosaic # in the project, but a project might e.g. only contain mosaics number # 5 and 6 (so "2" would be correct but mcount is "6"): self.experiment['mcount'] = int(root.find('NumberOfMosaics').text) # currently we only support LTR and TTB experiments: if not self.experiment['xdir'] == 'LeftToRight': raise TypeError('Unsupported XAxis configuration') if not self.experiment['ydir'] == 'TopToBottom': raise TypeError('Unsupported YAxis configuration') def parse_all_mosaics(self): """Wrapper to parse all mosaic parts. Call the mosaic parser for all "Mosaic" XML subtrees and collect the resulting dicts in the object's mosaics variable. """ for mosaic_subtree in self.tree.getroot().findall('Mosaic'): self.mosaics.append(self.parse_mosaic(mosaic_subtree)) def parse_mosaic(self, mosaic_xmltree): """Parse a mosaic XML subtree and assemble a dict from it. Parameters ---------- mosaic_xmltree : xml.etree.ElementTree.Element The subtree of the XML ElementTree containing the details of a single mosaic. Returns ------- mosaic : {'id': int, 'ratio': float, # non-overlapping tile percentage 'xcount': int, # number of tiles in X 'ycount': int, # number of tiles in Y 'tiles': [{ 'imgf': str, # tile filename 'imgid': int, # tile ID 'xno': int, # tile index in X direction 'yno': int, # tile index in Y direction 'xpos': float, # tile position in X direction 'ypos': float # tile position in Y direction }] } """ idx = int(mosaic_xmltree.attrib['No']) assert mosaic_xmltree.find('XScanDirection').text == 'LeftToRight' assert mosaic_xmltree.find('YScanDirection').text == 'TopToBottom' xcount = int(mosaic_xmltree.find('XImages').text) ycount = int(mosaic_xmltree.find('YImages').text) ratio = float(mosaic_xmltree.find('IndexRatio').text) log.info('Mosaic %i: %ix%i' % (idx, xcount, ycount)) # warn if overlap is below 5 percent: if (ratio > 95.0): log.warn('WARNING: overlap of mosaic %i is only %.1f%%!' % (idx, (100.0 - ratio))) images = [] for img in mosaic_xmltree.findall('ImageInfo'): info = { 'imgid': int(img.find('No').text), 'xpos': float(img.find('XPos').text), 'ypos': float(img.find('YPos').text), 'xno': int(img.find('Xno').text), 'yno': int(img.find('Yno').text), 'imgf': img.find('Filename').text } images.append(info) return({'id': idx, 'xcount': xcount, 'ycount': ycount, 'ratio': ratio, 'tiles': images}) def gen_tile_config(self, idx, fixpath=False): """Generate a tile configuration for Fiji's stitcher. Generate a layout configuration file for a ceartain mosaic in the format readable by Fiji's "Grid/Collection stitching" plugin. The configuration is stored in a file in the input directory carrying the mosaic's index number as a suffix. Parameters ---------- idx : int The index of the mosaic to generate the tile config for. fixpath : bool (optional) Determines if the path separators in the tile config file should be kept as the are or be adjusted to the currently used environment. Returns ------- config : list(str) The tile configuration as a list of strings, one per line. """ # TAG: move_to_superclass conf = list() app = conf.append app('# Define the number of dimensions we are working on\n') app('dim = 3\n') app('# Define the image coordinates (in pixels)\n') try: size = self.dim_from_oif(self.mosaics[idx]['tiles'][0]['imgf']) except IOError, err: # if reading the OIF fails, we just issue a warning and continue # with the next mosaic: log.warn('\n*** WARNING *** WARNING *** WARNING ***\n%s' % err) log.warn('=====> SKIPPING MOSAIC %i <=====\n' % idx) return ratio = self.mosaics[idx]['ratio'] / 100 for img in self.mosaics[idx]['tiles']: xpos = img['xno'] * ratio * size[0] ypos = img['yno'] * ratio * size[1] # fix wrong filenames from stupid Olympus software: imgf = img['imgf'].replace('.oif', '_01.oif') if(fixpath): imgf = imgf.replace('\\', sep) app('%s; ; (%f, %f, %f)\n' % (imgf, xpos, ypos, 0)) return(conf) def write_tile_config(self, idx, path='', fixpath=False): """Generate and write the tile configuration file. Call the method to generate the corresponding tile configuration and store the result in a file. The naming scheme is "mosaic_xyz.txt" where "xyz" is the zero-padded index number of this particular mosaic. Parameters ---------- idx : int Index number of the mosaic to write the tile config for. path : str (optional) The output directory, if empty the input directory is used. fixpath : bool (optional) Passed on to gen_tile_config(). """ # TAG: move_to_superclass config = self.gen_tile_config(idx, fixpath) # filename is zero-padded to the total number of mosaics: fname = 'mosaic_%0*i.txt' % (len(str(len(self.mosaics))), idx) if(path == ''): fname = join(self.infile['path'], fname) else: fname = join(path, fname) out = open(fname, 'w') out.writelines(config) out.close() log.warn('Wrote tile config to %s' % out.name) def write_all_tile_configs(self, path='', fixpath=False): """Wrapper to generate all TileConfiguration.txt files. All arguments are directly passed on to write_tile_config(). """ # TAG: move_to_superclass for i in xrange(self.experiment['mcount']): self.write_tile_config(i, path, fixpath) def dim_from_oif(self, oif): """Read image dimensions from a .oif file. Parameters ---------- oif : str The .oif file to read the dimensions from. Returns ------- dim : (int, int) Pixel dimensions in X and Y direction as tuple. """ oif = oif.replace('\\', sep) oif = oif.replace('.oif', '_01.oif') oif = join(self.infile['path'], oif) log.debug('Parsing OIF file for dimensions: %s' % oif) # we're using ConfigParser which can't handle UTF-16 (and UTF-8) files # properly, so we need the help of "codecs" to parse the file try: conv = codecs.open(oif, "r", "utf16") except IOError: raise IOError("Can't find required OIF file for parsing image" + " dimensions: %s" % oif) parser = ConfigParser.RawConfigParser() parser.readfp(conv) try: dim_h = parser.get(u'Reference Image Parameter', u'ImageHeight') dim_w = parser.get(u'Reference Image Parameter', u'ImageWidth') except ConfigParser.NoOptionError: raise ValueError("Can't read image dimensions from %s." % oif) dim = (int(dim_w), int(dim_h)) log.warn('Dimensions: %s %s' % dim) return dim def gen_stitching_macro_code(self, pfx, path=''): """Generate code in ImageJ's macro language to stitch the mosaics. Take two template files ("head" and "body") and generate an ImageJ macro to stitch the mosaics. Using the splitted templates allows for setting default values in the head that can be overridden in this generator method (the ImageJ macro language doesn't have a command to check if a variable is set or not, it just exits with an error). Parameters ---------- pfx : str The prefix for the two template files, will be completed with the corresponding suffixes "_head.ijm" and "_body.ijm". path : str (optional) The path to use as input directory *INSIDE* the macro. Returns ------- ijm : list(str) The generated macro code as a list of str (one str per line). """ # TAG: move_to_superclass mcount = self.experiment['mcount'] # templates are expected in a subdir of the current package: basedir = dirname(__file__) + sep + 'ijm_templates' + sep log.info('Template directory: %s' % basedir) tpl = open(basedir + pfx + '_head.ijm', 'r') ijm = tpl.readlines() tpl.close() ijm.append('\n') ijm.append('name = "%s";\n' % self.infile['dname']) ijm.append('padlen = %i;\n' % len(str(mcount))) ijm.append('mcount = %i;\n' % mcount) # windows path separator (in)sanity: path = path.replace('\\', '\\\\') ijm.append('input_dir="%s";\n' % path) ijm.append('use_batch_mode = true;\n') # If the overlap is below a certain level (5 percent), we disable # computing the actual positions and subpixel accuracy: if (self.mosaics[0]['ratio'] > 95.0): ijm.append('compute = false;\n') ijm.append('\n') tpl = open(basedir + pfx + '_body.ijm', 'r') ijm += tpl.readlines() tpl.close() log.debug('--- ijm ---\n%s\n--- ijm ---' % ijm) return(ijm) def write_stitching_macro(self, code, fname=None, dname=None): """Write generated macro code into a file. Parameters ---------- code : list(str) The code as a list of strings, one per line. fname : str (optional) The desired output filename, if empty the directory name (usually describing the dataset) is used with a generic suffix. dname : str (optional) The output directory, if empty the input directory is used. """ if fname is None: fname = self.infile['dname'] + '_stitch_all.ijm' if dname is None: # if not requested other, write to input directory: fname = join(self.infile['path'], fname) else: fname = dname + sep + fname out = open(fname, 'w') out.writelines(code) out.close() log.warn('Wrote macro template to %s' % out.name) if __name__ == "__main__": print('Running doctest on file "%s".' % __file__) import doctest doctest.testmod()