Coverage for python/lsst/summit/extras/imageSorter.py: 17%
128 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-30 04:58 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-30 04:58 -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 os
23import pickle
24import re
25from os import system
27import matplotlib.pyplot as plt
28from PIL import Image
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 = (
43 TAGS
44 + "\n"
45 + """
46 = - apply the same annotations as the previous image
47 To enter no tags but some notes, just start with a space
48 """
49)
52class ImageSorter:
53 """Take a list on png files, as created by lsst.summit.extras.animator
54 and tag each dataId with a number of attributes.
56 Returns a dict of dataId dictionaries with values being the corresponding
57 """
59 def __init__(self, fileList, outputFilename):
60 self.fileList = fileList
61 self.outputFilename = outputFilename
63 @staticmethod
64 def _getDataIdFromFilename(filename):
65 # filename of the form 2021-02-18-705-quickLookExp.png
66 filename = os.path.basename(filename)
67 mat = re.match(r"^(\d{4}-\d{2}-\d{2})-(\d*)-.*$", filename)
68 if not mat:
69 raise RuntimeError(f"Failed to extract dayObs/seqNum from {filename}")
70 dayObs = mat.group(1)
71 seqNum = int(mat.group(2))
72 return (dayObs, seqNum)
74 def getPreviousAnnotation(self, info, imNum):
75 if imNum == 0:
76 raise RuntimeError("There is no previous annotation for the first image.")
78 previousFilename = self.fileList[imNum - 1]
79 previousDataId = self._getDataIdFromFilename(previousFilename)
80 previousAnnotation = info[previousDataId]
81 return previousAnnotation
83 def addData(self, dataId, info, answer, mode, imNum):
84 """Modes = O(verwrite), S(kip), A(ppend)"""
85 if "=" in answer:
86 answer = self.getPreviousAnnotation(info, imNum)
88 if dataId not in info:
89 info[dataId] = answer
90 return
92 if mode == "O":
93 info[dataId] = answer
94 elif mode in ["B", "A"]:
95 oldAnswer = info[dataId]
96 answer = "".join([oldAnswer, answer])
97 info[dataId] = answer
98 else:
99 raise RuntimeError(f"Unrecognised mode {mode} - should be impossible")
100 return
102 @classmethod
103 def loadAnnotations(cls, pickleFilename):
104 """Load back and split up annotations for easy use.
106 Anything after a space is returned as a whole string,
107 anything before it is lower-cased and returned as tags.
109 from lsst.summit.extras import ImageSorter
110 tags, notes = ImageSorter.loadAnnotations(pickleFilename)
111 """
112 loaded = cls._load(pickleFilename)
114 tags, notes = {}, {}
116 for dataId, answerFull in loaded.items():
117 answer = answerFull.lower()
118 if answerFull.startswith(" "): # notes only case
119 tags[dataId] = ""
120 notes[dataId] = answerFull.strip()
121 continue
123 if " " in answer:
124 answer = answerFull.split()[0]
125 notes[dataId] = " ".join([_ for _ in answerFull.split()[1:]])
126 tags[dataId] = answer.upper()
128 return tags, notes
130 @staticmethod
131 def _load(filename):
132 """Internal loading only.
134 Not to be used by users for reading back annotations"""
135 with open(filename, "rb") as pickleFile:
136 info = pickle.load(pickleFile)
137 return info
139 @staticmethod
140 def _save(info, filename):
141 with open(filename, "wb") as dumpFile:
142 pickle.dump(info, dumpFile)
144 def sortImages(self):
145 mode = "A"
146 info = {}
147 if os.path.exists(self.outputFilename):
148 info = self._load(self.outputFilename)
150 print(f"Output file {self.outputFilename} exists with info on {len(info)} files:")
151 print("Press A - view all images, appending info to existing entries")
152 print("Press O - view all images, overwriting existing entries")
153 print("Press S - skip all images with existing annotations, including blank annotations")
154 print("Press B - skip all images with annotations that are not blank")
155 print("Press D - just display existing data and exit")
156 print("Press Q to quit")
157 mode = input()
158 mode = mode[0].upper()
160 if mode == "Q":
161 exit()
162 elif mode == "D":
163 for dataId, value in info.items():
164 print(f"{dataId[0]} - {dataId[1]}: {value}")
165 exit()
166 elif mode in "AOSB":
167 pass
168 else:
169 print("Unrecognised response - try again")
170 self.sortImages()
171 return # don't run twice in this case!
173 # need to write file first, even if empty, because _load and _save
174 # are inside the loop to ensure that annotations aren't lost even on
175 # full crash
176 print(INSTRUCTIONS)
177 self._save(info, self.outputFilename)
179 plt.figure(figsize=(10, 10))
180 for imNum, filename in enumerate(self.fileList):
181 info = self._load(self.outputFilename)
183 dataId = self._getDataIdFromFilename(filename)
184 if dataId in info and mode in ["S", "B"]: # always skip if found for S and if not blank for B
185 if (mode == "S") or (mode == "B" and info[dataId] != ""):
186 continue
188 with Image.open(filename) as pilImage:
189 pilImage = Image.open(filename)
190 width, height = pilImage.size
191 cropLR, cropUD = 100 - 50, 180 - 50
192 cropped = pilImage.crop((cropLR, cropUD, width - cropLR, height - cropUD))
193 plt.clf()
194 plt.imshow(cropped, interpolation="bicubic")
195 plt.show(block=False)
196 plt.draw() # without this you get the same image each time
197 plt.tight_layout()
198 osascriptCall = """/usr/bin/osascript -e 'tell app "Finder" to """
199 osascriptCall += """set frontmost of process "Terminal" to true' """
200 system(osascriptCall)
202 oldAnswer = None # just so we can display existing info with the dataId
203 if dataId in info:
204 oldAnswer = info[dataId]
205 inputStr = f"{dataId[0]} - {dataId[1]}: %s" % ("" if oldAnswer is None else oldAnswer)
206 answer = input(inputStr)
207 if "exit" in answer:
208 break # break don't exit so data is written!
210 self.addData(dataId, info, answer, mode, imNum)
211 self._save(info, self.outputFilename)
213 print(f"Info written to {self.outputFilename}")
215 return info
218if __name__ == "__main__": 218 ↛ 220line 218 didn't jump to line 220
219 # TODO: DM-34239 Remove this
220 fileList = [
221 "/Users/merlin/rsync/animatorOutput/pngs/dayObs-2020-02-17-seqNum-232-calexp.png",
222 "/Users/merlin/rsync/animatorOutput/pngs/dayObs-2020-02-17-seqNum-233-calexp.png",
223 "/Users/merlin/rsync/animatorOutput/pngs/dayObs-2020-02-17-seqNum-234-calexp.png",
224 "/Users/merlin/rsync/animatorOutput/pngs/dayObs-2020-02-17-seqNum-235-calexp.png",
225 ]
227 sorter = ImageSorter(fileList, "/Users/merlin/scratchfile.txt")
228 sorter.sortImages()