Coverage for python/lsst/summit/extras/imageSorter.py: 13%
128 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-07 03:45 -0700
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-07 03:45 -0700
1# This file is part of summit_extras.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22import pickle
23from PIL import Image
24import matplotlib.pyplot as plt
25import os
26from os import system
27import re
30TAGS = """
31 - (Blank/no annotation) - nominally good, i.e. nothing notable in the image
32Q - bad main star location (denoted by cross-hair on image sorter)
33F - Obviously very poor focus (worse than just seeing, does NOT include donuts)
34D - Donut image
35O - Occlusion (dome or door)
36V - No back bias suspected
37P - Non-standard PSF (rotator/mount issues/tracking error, etc)
38S - Satellite or plane crossing image
39! - Something interesting/crazy - see notes on image
40"""
42INSTRUCTIONS = (TAGS + '\n' +
43 """
44 = - apply the same annotations as the previous image
45 To enter no tags but some notes, just start with a space
46 """)
49class ImageSorter():
50 """Take a list on png files, as created by lsst.summit.extras.animator
51 and tag each dataId with a number of attributes.
53 Returns a dict of dataId dictionaries with values being the corresponding
54 """
56 def __init__(self, fileList, outputFilename):
57 self.fileList = fileList
58 self.outputFilename = outputFilename
60 @staticmethod
61 def _getDataIdFromFilename(filename):
62 # filename of the form 2021-02-18-705-quickLookExp.png
63 filename = os.path.basename(filename)
64 mat = re.match(r'^(\d{4}-\d{2}-\d{2})-(\d*)-.*$', filename)
65 if not mat:
66 raise RuntimeError(f"Failed to extract dayObs/seqNum from {filename}")
67 dayObs = mat.group(1)
68 seqNum = int(mat.group(2))
69 return (dayObs, seqNum)
71 def getPreviousAnnotation(self, info, imNum):
72 if imNum == 0:
73 raise RuntimeError("There is no previous annotation for the first image.")
75 previousFilename = self.fileList[imNum-1]
76 previousDataId = self._getDataIdFromFilename(previousFilename)
77 previousAnnotation = info[previousDataId]
78 return previousAnnotation
80 def addData(self, dataId, info, answer, mode, imNum):
81 """Modes = O(verwrite), S(kip), A(ppend)"""
82 if '=' in answer:
83 answer = self.getPreviousAnnotation(info, imNum)
85 if dataId not in info:
86 info[dataId] = answer
87 return
89 if mode == 'O':
90 info[dataId] = answer
91 elif mode in ['B', 'A']:
92 oldAnswer = info[dataId]
93 answer = "".join([oldAnswer, answer])
94 info[dataId] = answer
95 else:
96 raise RuntimeError(f"Unrecognised mode {mode} - should be impossible")
97 return
99 @classmethod
100 def loadAnnotations(cls, pickleFilename):
101 """Load back and split up annotations for easy use.
103 Anything after a space is returned as a whole string,
104 anything before it is lower-cased and returned as tags.
106 from lsst.summit.extras import ImageSorter
107 tags, notes = ImageSorter.loadAnnotations(pickleFilename)
108 """
109 loaded = cls._load(pickleFilename)
111 tags, notes = {}, {}
113 for dataId, answerFull in loaded.items():
114 answer = answerFull.lower()
115 if answerFull.startswith(' '): # notes only case
116 tags[dataId] = ''
117 notes[dataId] = answerFull.strip()
118 continue
120 if " " in answer:
121 answer = answerFull.split()[0]
122 notes[dataId] = " ".join([_ for _ in answerFull.split()[1:]])
123 tags[dataId] = answer.upper()
125 return tags, notes
127 @staticmethod
128 def _load(filename):
129 """Internal loading only.
131 Not to be used by users for reading back annotations"""
132 with open(filename, "rb") as pickleFile:
133 info = pickle.load(pickleFile)
134 return info
136 @staticmethod
137 def _save(info, filename):
138 with open(filename, "wb") as dumpFile:
139 pickle.dump(info, dumpFile)
141 def sortImages(self):
142 mode = 'A'
143 info = {}
144 if os.path.exists(self.outputFilename):
145 info = self._load(self.outputFilename)
147 print(f'Output file {self.outputFilename} exists with info on {len(info)} files:')
148 print('Press A - view all images, appending info to existing entries')
149 print('Press O - view all images, overwriting existing entries')
150 print('Press S - skip all images with existing annotations, including blank annotations')
151 print('Press B - skip all images with annotations that are not blank')
152 print('Press D - just display existing data and exit')
153 print('Press Q to quit')
154 mode = input()
155 mode = mode[0].upper()
157 if mode == 'Q':
158 exit()
159 elif mode == 'D':
160 for dataId, value in info.items():
161 print(f"{dataId[0]} - {dataId[1]}: {value}")
162 exit()
163 elif mode in 'AOSB':
164 pass
165 else:
166 print("Unrecognised response - try again")
167 self.sortImages()
168 return # don't run twice in this case!
170 # need to write file first, even if empty, because _load and _save
171 # are inside the loop to ensure that annotations aren't lost even on
172 # full crash
173 print(INSTRUCTIONS)
174 self._save(info, self.outputFilename)
176 plt.figure(figsize=(10, 10))
177 for imNum, filename in enumerate(self.fileList):
178 info = self._load(self.outputFilename)
180 dataId = self._getDataIdFromFilename(filename)
181 if dataId in info and mode in ['S', 'B']: # always skip if found for S and if not blank for B
182 if (mode == 'S') or (mode == 'B' and info[dataId] != ""):
183 continue
185 with Image.open(filename) as pilImage:
186 pilImage = Image.open(filename)
187 width, height = pilImage.size
188 cropLR, cropUD = 100-50, 180-50
189 cropped = pilImage.crop((cropLR, cropUD, width-cropLR, height-cropUD))
190 plt.clf()
191 plt.imshow(cropped, interpolation="bicubic")
192 plt.show(block=False)
193 plt.draw() # without this you get the same image each time
194 plt.tight_layout()
195 osascriptCall = '''/usr/bin/osascript -e 'tell app "Finder" to '''
196 osascriptCall += '''set frontmost of process "Terminal" to true' '''
197 system(osascriptCall)
199 oldAnswer = None # just so we can display existing info with the dataId
200 if dataId in info:
201 oldAnswer = info[dataId]
202 inputStr = f"{dataId[0]} - {dataId[1]}: %s" % ("" if oldAnswer is None else oldAnswer)
203 answer = input(inputStr)
204 if 'exit' in answer:
205 break # break don't exit so data is written!
207 self.addData(dataId, info, answer, mode, imNum)
208 self._save(info, self.outputFilename)
210 print(f'Info written to {self.outputFilename}')
212 return info
215if __name__ == '__main__': 215 ↛ 217line 215 didn't jump to line 217, because the condition on line 215 was never true
216 # TODO: DM-34239 Remove this
217 fileList = ['/Users/merlin/rsync/animatorOutput/pngs/dayObs-2020-02-17-seqNum-232-calexp.png',
218 '/Users/merlin/rsync/animatorOutput/pngs/dayObs-2020-02-17-seqNum-233-calexp.png',
219 '/Users/merlin/rsync/animatorOutput/pngs/dayObs-2020-02-17-seqNum-234-calexp.png',
220 '/Users/merlin/rsync/animatorOutput/pngs/dayObs-2020-02-17-seqNum-235-calexp.png']
222 sorter = ImageSorter(fileList, '/Users/merlin/scratchfile.txt')
223 sorter.sortImages()