dataset.py 17.9 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
# -*- coding: utf-8 -*-
"""
GEPARD - Gepard-Enabled PARticle Detection
Copyright (C) 2018  Lars Bittrich and Josef Brandt, Leibniz-Institut für 
Polymerforschung Dresden e. V. <bittrich-lars@ipfdd.de>    

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program, see COPYING.  
If not, see <https://www.gnu.org/licenses/>.
"""
import os
import pickle
import numpy as np
24
import sys
25
import cv2
26
from copy import copy
Josef Brandt's avatar
Josef Brandt committed
27
from typing import List, Dict, Tuple, TYPE_CHECKING
28 29
from .analysis.particleContainer import ParticleContainer
from .legacyConvert import legacyConversion, currentVersion
30
from .helperfunctions import cv2imwrite_fix, cv2imread_fix
31
from .coordTransform import CoordTransfer
Josef Brandt's avatar
Josef Brandt committed
32

Josef Brandt's avatar
Josef Brandt committed
33
if TYPE_CHECKING:
Josef Brandt's avatar
Josef Brandt committed
34
    from .coordTransform import TrayMarker, ImageMarker
Josef Brandt's avatar
Josef Brandt committed
35

36 37 38
# for legacy pickle import the old module name dataset must be found 
# (no relative import)
from . import dataset
JosefBrandt's avatar
JosefBrandt committed
39
from . import analysis
40
sys.modules['dataset'] = dataset
JosefBrandt's avatar
JosefBrandt committed
41
sys.modules['analysis'] = analysis
Josef Brandt's avatar
Josef Brandt committed
42 43 44
sys.modules['gepardevaluation.dataset'] = dataset
sys.modules['gepardevaluation.analysis.particleContainer'] = analysis.particleContainer
sys.modules['gepardevaluation.analysis.particleAndMeasurement'] = analysis.particleAndMeasurement
45

46

47 48 49 50 51 52 53 54 55
def loadData(fname):
    retds = None
    with open(fname, "rb") as fp:
        ds = pickle.load(fp)
        ds.fname = fname
        ds.readin = True
        retds = DataSet(fname)
        retds.version = 0
        retds.__dict__.update(ds.__dict__)
Josef Brandt's avatar
Josef Brandt committed
56
        retds.updatePath()
Josef Brandt's avatar
Josef Brandt committed
57

58
        if retds.version < currentVersion:
59
            legacyConversion(retds)
60
        elif retds.zvalimg == "saved":
61
            retds.loadZvalImg()
Josef Brandt's avatar
Josef Brandt committed
62

Josef Brandt's avatar
Josef Brandt committed
63
        retds.maxdim = None
Josef Brandt's avatar
Josef Brandt committed
64
        retds.afterLoad()
65 66
    return retds

Josef Brandt's avatar
Josef Brandt committed
67

68
def saveData(dataset, fname):
Josef Brandt's avatar
Josef Brandt committed
69 70 71 72 73 74 75 76 77 78 79
    with open(fname, "wb") as fp:
        # zvalimg is rather large and thus it is saved separately in a tif file
        # only onces after its creation
        zvalimg = dataset.zvalimg
        if zvalimg is not None:
            dataset.zvalimg = "saved"

        dataset.beforeSave()
        pickle.dump(dataset, fp, protocol=-1)
        dataset.zvalimg = zvalimg
        dataset.afterSave()
Josef Brandt's avatar
Josef Brandt committed
80 81


82
def arrayCompare(a1, a2):
Josef Brandt's avatar
Josef Brandt committed
83
    if a1.shape != a2.shape:
Lars Bittrich's avatar
Lars Bittrich committed
84
        return False
Josef Brandt's avatar
Josef Brandt committed
85 86
    if a1.dtype != np.float32 and a1.dtype != np.float64:
        return np.all(a1 == a2)
87 88
    ind = np.isnan(a1)
    if not np.any(ind):
Josef Brandt's avatar
Josef Brandt committed
89 90 91
        return np.all(a1 == a2)
    return np.all(a1[~ind] == a2[~ind])

92 93

def listCompare(l1, l2):
Josef Brandt's avatar
Josef Brandt committed
94
    if len(l1) != len(l2):
95 96 97 98 99 100 101 102
        return False
    for l1i, l2i in zip(l1, l2):
        if isinstance(l1i, np.ndarray):
            if not isinstance(l2i, np.ndarray) or not arrayCompare(l1i, l2i):
                return False
        elif isinstance(l1i, (list, tuple)):
            if not isinstance(l2i, (list, tuple)) or not listCompare(l1i, l2i):
                return False
Josef Brandt's avatar
Josef Brandt committed
103
        elif l1i != l2i and ((~np.isnan(l1i)) or (~np.isnan(l2i))):
104 105 106
            return False
    return True

Josef Brandt's avatar
Josef Brandt committed
107

108 109 110
def recursiveDictCompare(d1, d2):
    for key in d1:
        if not key in d2:
Lars Bittrich's avatar
Lars Bittrich committed
111
            print("key missing in d2:", key, flush=True)
112 113 114
            return False
        a = d1[key]
        b = d2[key]
Lars Bittrich's avatar
Lars Bittrich committed
115
        print(key, type(a), type(b), flush=True)
116 117
        if isinstance(a, np.ndarray):
            if not isinstance(b, np.ndarray) or not arrayCompare(a, b):
Lars Bittrich's avatar
Lars Bittrich committed
118
                print("data is different!", a, b)
119 120 121
                return False
        elif isinstance(a, dict):
            if not isinstance(b, dict):
Lars Bittrich's avatar
Lars Bittrich committed
122
                print("data is different!", a, b)
123 124 125 126 127
                return False
            if not recursiveDictCompare(a, b):
                return False
        elif isinstance(a, (list, tuple)):
            if not isinstance(b, (list, tuple)) or not listCompare(a, b):
Lars Bittrich's avatar
Lars Bittrich committed
128
                print("data is different!", a, b)
129 130
                return False
        elif a != b:
Lars Bittrich's avatar
Lars Bittrich committed
131 132 133
            if (a is not None) and (b is not None):
                print("data is different!", a, b)
                return False
134 135
    return True

136

137
class DataSet(object):
Josef Brandt's avatar
Josef Brandt committed
138 139 140 141 142 143 144 145 146 147 148
    uid: int = 0

    @classmethod
    def getUID(cls):
        """
        returns a unique number managed by the dataset. will be saved and restored on load
        :return int:
        """
        cls.uid += 1
        return cls.uid

149 150
    def __init__(self, fname, newProject=False):
        self.fname = fname
Josef Brandt's avatar
Josef Brandt committed
151
        self.path, self.name = None, None
152
        # parameters specifically for optical scan
153
        self.version = currentVersion
Josef Brandt's avatar
Josef Brandt committed
154 155
        self.lastpos = None  # x, y of center of lower left corner tile of image
        self.maxdim = None  # x0, y0, x1, y1. x0, y0 = lastpos, x1, y1 = center of upper right corner tile of image. Only used during optical scan.
156 157
        self.pixelscale_df = None  # µm / pixel --> scale of DARK FIELD camera (used for image stitching)
        self.pixelscale_bf = None  # µm / pixel of DARK FIELD camera (set to same as bright field, if both use the same camera)
158 159
        self.imagedim_bf = None  # width, height, angle of BRIGHT FIELD camera
        self.imagedim_df = None  # width, height, angle of DARK FIELD camera (set to same as bright field, if both use the same camera)
160
        self.imagescanMode = 'df'  # was the fullimage acquired in dark- or brightfield?
161
        self.fitpoints: np.ndarray = np.array([])  # manually adjusted positions aquired to define the specimen geometry
162
        self.fitindices = []  # which of the five positions in the ui are already known
Josef Brandt's avatar
Josef Brandt committed
163 164
        self.boundary = []  # scan boundary computed by a circle around the fitpoints + manual adjustments
        self.grid = []  # scan grid positions for optical scan
165
        self.zpositions = np.array([0])  # z-positions for optical scan
166
        self.heightmapParams: Tuple[float, float, float] = None  # convert x, y coordinate to z coordinate (see self.mapHeight)
167
        self.zvalimg = None
168
        self.importedWithPlaceholderZValImg: bool = False
169
        self.coordinatetransform: CoordTransfer = None
170 171
        self.signx = 1.
        self.signy = -1.
Josef Brandt's avatar
Josef Brandt committed
172 173
        self.trayMarkers: List['TrayMarker'] = []
        self.imageMarkers: List['ImageMarker'] = []
174 175 176

        # tiling parameters
        self.pyramidParams = None
Josef Brandt's avatar
Josef Brandt committed
177 178 179 180

        # navigation params (e.g. visited particles)
        self.particleNavigationParams = None

181
        # parameters specifically for raman scan
Josef Brandt's avatar
Josef Brandt committed
182
        self.pshift = None  # shift of spectrometer scan position relative to image center
183 184
        self.seedpoints: np.ndarray = np.array([])  # shape Nx3 array of N points, each storing x, y and radius
        self.seeddeletepoints: np.ndarray = np.array([])  # shape Nx3 array of N points, each storing x, y and radius
JosefBrandt's avatar
JosefBrandt committed
185

Josef Brandt's avatar
Josef Brandt committed
186
        self.particleContainer = ParticleContainer()
187
        self.particleDetectionDone = False
188

Josef Brandt's avatar
Josef Brandt committed
189
        self.hqiThresholds: Dict[int, Tuple[float, float]] = {}  # key: SpecSeriesIndex, Value: (minHQI, maxHQI)
190
        self.colorSeed = 'default'
191
        self._particleTypeGroups: Dict[str, List[str]] = {}  # Key: Groupname, Value: List of Assignments belonging to the group
192
        self.resultsUploadedToSQL = []
Josef Brandt's avatar
Josef Brandt committed
193 194 195

        self.readin = True  # a value that is always set to True at loadData
        # and mark that the coordinate system might be changed in the meantime
196 197 198 199
        self.mode = "prepare"
        if newProject:
            self.fname = self.newProject(fname)
        self.updatePath()
Josef Brandt's avatar
Josef Brandt committed
200 201 202 203 204

    @property
    def opticalScanDone(self) -> bool:
        return os.path.exists(self.getZvalImageName())

Josef Brandt's avatar
Josef Brandt committed
205 206
    @property
    def specscandone(self) -> bool:
Josef Brandt's avatar
Josef Brandt committed
207
        return len(self.particleContainer.completedSpecScans) > 0
Josef Brandt's avatar
Josef Brandt committed
208

209
    @property
Josef Brandt's avatar
Josef Brandt committed
210
    def coordinateSystemConfigured(self) -> bool:
211 212 213 214 215 216 217 218 219 220
        hasPixelScale = None not in [self.pixelscale_df, self.pixelscale_bf]
        hasDims = self.imagedim_df is not None and self.imagedim_bf is not None
        hasPos = self.lastpos is not None and self.maxdim is not None
        return hasPixelScale and hasDims and hasPos

    def invalidateCoordinateSystem(self) -> None:
        self.pixelscale_bf = self.pixelscale_df = None
        self.imagedim_bf = self.imagedim_df = None
        self.lastpos = self.maxdim = None

221 222
    def deleteAllMeasurements(self) -> None:
        self.particleContainer.completedSpecScans = []
Josef Brandt's avatar
Josef Brandt committed
223

224 225
    def __eq__(self, other):
        return recursiveDictCompare(self.__dict__, other.__dict__)
226

Josef Brandt's avatar
Josef Brandt committed
227 228 229 230 231 232 233 234 235 236 237 238
    def afterLoad(self):
        self.particleContainer.datasetParent = self
        self.updatePath()
        DataSet.uid = self.uid

    def beforeSave(self):
        self.particleContainer.datasetParent = None
        self.uid = DataSet.uid

    def afterSave(self):
        self.afterLoad()

239 240 241 242 243
    def getPyramidParams(self):
        return self.pyramidParams

    def setPyramidParams(self, pyramid_params):
        self.pyramidParams = pyramid_params
Josef Brandt's avatar
Josef Brandt committed
244 245 246 247 248 249 250

    def getParticleNavigationParams(self):
        return self.particleNavigationParams

    def setParticleNavigationParams(self, part_nav_params):
        self.particleNavigationParams = part_nav_params

251 252 253
    def getPixelScale(self, mode=None):
        if mode is None:
            mode = self.imagescanMode
254
        return (self.pixelscale_df if mode == "df" else self.pixelscale_bf)
Josef Brandt's avatar
Josef Brandt committed
255

256 257 258
    def saveZvalImg(self):
        if self.zvalimg is not None:
            cv2imwrite_fix(self.getZvalImageName(), self.zvalimg)
259
            self.zvalimg = "saved"
260 261 262 263

    def loadZvalImg(self):
        if os.path.exists(self.getZvalImageName()):
            self.zvalimg = cv2imread_fix(self.getZvalImageName(), cv2.IMREAD_GRAYSCALE)
Josef Brandt's avatar
Josef Brandt committed
264

265 266
    def getZval(self, pixelpos):
        assert self.zvalimg is not None
267 268
        if self.zvalimg == "saved":
            self.loadZvalImg()
269
        i, j = int(round(pixelpos[1])), int(round(pixelpos[0]))
Josef Brandt's avatar
Josef Brandt committed
270
        if i >= self.zvalimg.shape[0]:
271
            print('error in getZval:', self.zvalimg.shape, i, j)
Josef Brandt's avatar
Josef Brandt committed
272 273
            i = self.zvalimg.shape[0] - 1
        if j >= self.zvalimg.shape[1]:
274
            print('error in getZval:', self.zvalimg.shape, i, j)
Josef Brandt's avatar
Josef Brandt committed
275 276
            j = self.zvalimg.shape[1] - 1
        zp = self.zvalimg[i, j]
277
        z0, z1 = self.zpositions[0], self.zpositions[-1]
278 279
        z = zp / 255. * (z1 - z0) + z0
        return z
Josef Brandt's avatar
Josef Brandt committed
280

Josef Brandt's avatar
Josef Brandt committed
281 282 283
    def mapHeight(self, x, y, force=False):
        if not force:
            assert not self.readin
284
        assert self.heightmapParams is not None
Josef Brandt's avatar
Josef Brandt committed
285 286
        z = self.heightmapParams[0] * x + self.heightmapParams[1] * y + self.heightmapParams[2]
        return z
287

Josef Brandt's avatar
Josef Brandt committed
288 289 290 291 292 293
    def calculateHeightmapParams(self, softwareZ: float, userZ: float) -> Tuple[np.ndarray, float]:
        """Calculates the heightmapPparamters from the given fitPoints, corrected by relative coordinate offsets.
        :param softwareZ: The current z-position, as indicated by the instrument software
        :param userZ: A current "user-z" position, ideally the same as software-z, or dependening on the instrument
        also a bit differently..
        """
294 295
        points: np.ndarray = np.float32(self.fitpoints)
        # convert z to software z, which is relative to current user z
Josef Brandt's avatar
Josef Brandt committed
296
        points[:, 2] += softwareZ - userZ
297 298 299 300 301 302 303 304
        A = np.ones((points.shape[0], 3))
        A[:, :2] = points[:, :2]
        b = points[:, 2]
        sol = np.linalg.lstsq(A, b, rcond=None)[0]
        self.heightmapParams = sol
        error: float = sol[0] * points[:, 0] + sol[1] * points[:, 1] + sol[2] - points[:, 2]
        return points, error

Josef Brandt's avatar
Josef Brandt committed
305
    def calculateLastPosAndMaxDimFromGrid(self) -> None:
306 307 308 309 310 311
        grid = np.asarray(self.grid)
        p0 = [grid[:, 0].min(), grid[:, 1].max()]
        p1 = [grid[:, 0].max(), grid[:, 1].min()]
        self.lastpos = p0
        self.maxdim = p0 + p1

Josef Brandt's avatar
Josef Brandt committed
312 313 314 315 316 317 318 319
    def calculateImageDimsFromZImg(self) -> None:
        """
        Calcualtes the Imagedims, as if the entire image was obtained in a single shot.
        """
        self.imagedim_df = np.array([self.zvalimg.shape[1] * self.pixelscale_df,
                                     self.zvalimg.shape[0] * self.pixelscale_df, 0.0])
        self.imagedim_bf = np.array([self.zvalimg.shape[1] * self.pixelscale_bf,
                                     self.zvalimg.shape[0] * self.pixelscale_bf, 0.0])
320

321 322 323 324
    def setZPositions(self, zLevels: np.ndarray) -> None:
        """Sets the zPositions, i.e., what z-Level OVER the calculated height map position (see self.mapHeight)
        corresponds each pixel of the zlevel-image"""
        self.zpositions = zLevels
Josef Brandt's avatar
Josef Brandt committed
325

326
    def mapToPixel(self, p, mode='df', force=False):
327 328 329
        if not force:
            assert not self.readin
        p0 = copy(self.lastpos)
Josef Brandt's avatar
Josef Brandt committed
330

331
        if self.coordinatetransform is not None:
Josef Brandt's avatar
Josef Brandt committed
332
            z = 0. if len(p) < 3 else p[2]
333
            T, pc = self.coordinatetransform.rotMatrix, self.coordinatetransform.offset
Josef Brandt's avatar
Josef Brandt committed
334
            p = (np.dot(np.array([p[0], p[1], z]) - pc, T.T))
Josef Brandt's avatar
Josef Brandt committed
335 336 337

        assert mode in ['df', 'bf'], f'mapToPixel mode: {mode} not understood'
        pixelscale: float = self.pixelscale_bf if mode == 'bf' else self.pixelscale_df
Josef Brandt's avatar
Josef Brandt committed
338 339 340
        p0[0] -= self.signx * self.imagedim_df[0] / 2
        p0[1] -= self.signy * self.imagedim_df[1] / 2
        x, y = self.signx * (p[0] - p0[0]) / pixelscale, self.signy * (p[1] - p0[1]) / pixelscale
341
        return x, y
Josef Brandt's avatar
Josef Brandt committed
342

343
    def mapToLength(self, pixelpos, mode='df', force=False, returnz=False):
344 345
        if not force:
            assert not self.readin
Josef Brandt's avatar
Josef Brandt committed
346
        if mode not in ['df', 'bf']:
347
            raise ValueError(f'mapToLength mode: {mode} not understood')
Josef Brandt's avatar
Josef Brandt committed
348

Josef Brandt's avatar
Josef Brandt committed
349 350 351 352 353 354
        p0 = copy(self.lastpos)
        pixelscale = self.pixelscale_df if mode == 'df' else self.pixelscale_bf
        imgdim = self.imagedim_df if mode == 'df' else self.imagedim_bf
        p0[0] -= self.signx * imgdim[0] / 2
        p0[1] -= self.signy * imgdim[1] / 2
        x, y = (p0[0] + self.signx * pixelpos[0] * pixelscale), (p0[1] + self.signy * pixelpos[1] * pixelscale)
Josef Brandt's avatar
Josef Brandt committed
355

356
        z = None
357
        if (returnz and self.zvalimg is not None) or self.coordinatetransform is not None:
Josef Brandt's avatar
Josef Brandt committed
358
            z = self.mapHeight(x, y, force)
359
            z += self.getZval(pixelpos)
360
        if self.coordinatetransform is not None:
361
            x, y, z = tuple(self.coordinatetransform.applyTransform(np.array([x, y, z])))
Josef Brandt's avatar
Josef Brandt committed
362

363 364 365
        if returnz:
            return x, y, z
        return x, y
Josef Brandt's avatar
Josef Brandt committed
366

Josef Brandt's avatar
Josef Brandt committed
367 368 369 370 371 372 373 374 375 376 377 378 379 380
    def mapToLengthWithoutCoordTransfer(self, pixelpos: tuple, mode: str = 'df') -> tuple:
        """
        Temporarily resets the cooridnate transform and returns original coordinates of the dataset.
        Used for determining a new coordinate transfer.
        :param pixelpos: Tuple of x, y pixel coordinates
        :param mode: Acquisition mode of optical image ('df' or 'bf')
        :return: Tuple (x, y, z) world coordinates
        """
        orgCoordTransform = self.coordinatetransform
        self.coordinatetransform = None
        x, y, z = self.mapToLength(pixelpos, mode, force=True, returnz=True)
        self.coordinatetransform = orgCoordTransform
        return x, y, z

381
    def mapToLengthSpectrometer(self, pixelpos, microscopeMode='df', noz=False):
382 383
        p0x, p0y, z = self.mapToLength(pixelpos, mode=microscopeMode, returnz=True)
        x, y = p0x + self.pshift[0], p0y + self.pshift[1]
384
        return x, y, z
Josef Brandt's avatar
Josef Brandt committed
385

Josef Brandt's avatar
Josef Brandt committed
386 387 388 389
    def saveNewImageMarkers(self, imgMarkerList: List['ImageMarker']) -> None:
        self.imageMarkers = imgMarkerList
        self.save()

390 391 392 393 394 395
    def newProject(self, fname):
        path = os.path.split(fname)[0]
        name = os.path.splitext(os.path.basename(fname))[0]
        newpath = os.path.join(path, name)
        fname = os.path.join(newpath, name + ".pkl")
        if not os.path.exists(newpath):
Josef Brandt's avatar
Josef Brandt committed
396
            os.mkdir(newpath)  # for new projects a directory will be created
397 398 399
        elif os.path.exists(fname):  # if this project is already there, load it instead
            self.__dict__.update(loadData(fname).__dict__)
        return fname
Josef Brandt's avatar
Josef Brandt committed
400

401 402 403 404 405 406
    def getTilePath(self):
        scandir = os.path.join(self.path, "tiles")
        if not os.path.exists(scandir):
            os.mkdir(scandir)
        return scandir

407 408 409 410 411
    def getScanPath(self):
        scandir = os.path.join(self.path, "scanimages")
        if not os.path.exists(scandir):
            os.mkdir(scandir)
        return scandir
Josef Brandt's avatar
Josef Brandt committed
412

413 414 415
    def getDetectParamsPath(self) -> str:
        return os.path.join(self.path, "detection.graph")

416 417 418
    def updatePath(self):
        self.path = os.path.split(self.fname)[0]
        self.name = os.path.splitext(os.path.basename(self.fname))[0]
Josef Brandt's avatar
Josef Brandt committed
419 420 421
        if hasattr(self.particleContainer, 'completedSpecScans'):  # Will be false for older versions. Will be created in legacy convert
            for measSeries in self.particleContainer.completedSpecScans:
                measSeries.setUpToDataset(self)
Josef Brandt's avatar
Josef Brandt committed
422

JosefBrandt's avatar
JosefBrandt committed
423
    def getSpectraFileName(self):
JosefBrandt's avatar
 
JosefBrandt committed
424
        return os.path.join(self.path, 'spectra.npy')
Josef Brandt's avatar
Josef Brandt committed
425

Josef Brandt's avatar
Josef Brandt committed
426 427 428
    def getProjectFilePath(self) -> str:
        return self.fname

429
    def getImageName(self):
430 431
        return os.path.join(self.path, 'fullimage.tif')

432 433
    def getZvalImageName(self):
        return os.path.join(self.path, "zvalues.tif")
Josef Brandt's avatar
Josef Brandt committed
434

435 436
    def getLegacyImageName(self):
        return os.path.join(self.path, "fullimage.png")
Josef Brandt's avatar
Josef Brandt committed
437

Josef Brandt's avatar
 
Josef Brandt committed
438 439
    def getBackgroundImageName(self):
        return os.path.join(self.path, "background.bmp")
440 441 442 443 444 445
    
    def getParticleTypeGroups(self) -> Dict[str, List[str]]:
        return self._particleTypeGroups

    def setParticleTypeGroups(self, newGroups: Dict[str, List[str]]):
        self._particleTypeGroups = newGroups
Josef Brandt's avatar
Josef Brandt committed
446

447 448
    def getTmpImageName(self):
        return os.path.join(self.path, "tmp.bmp")
Josef Brandt's avatar
Josef Brandt committed
449

450 451
    def save(self):
        saveData(self, self.fname)
Josef Brandt's avatar
Josef Brandt committed
452

453 454
    def saveBackup(self):
        inc = 0
Hackmet's avatar
Hackmet committed
455
        while True:
456 457 458 459 460 461 462
            directory = os.path.dirname(self.fname)
            filename = self.name + '_backup_' + str(inc) + '.pkl'
            path = os.path.join(directory, filename)
            if os.path.exists(path):
                inc += 1
            else:
                saveData(self, path)
Hackmet's avatar
Hackmet committed
463
                return filename