Coverage for python/lsst/summit/extras/imageSorter.py: 17%

128 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-08 12:47 +0000

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/>. 

21 

22import os 

23import pickle 

24import re 

25from os import system 

26 

27import matplotlib.pyplot as plt 

28from PIL import Image 

29 

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""" 

41 

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) 

50 

51 

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. 

55 

56 Returns a dict of dataId dictionaries with values being the corresponding 

57 """ 

58 

59 def __init__(self, fileList: list[str], outputFilename: str): 

60 self.fileList = fileList 

61 self.outputFilename = outputFilename 

62 

63 @staticmethod 

64 def _getDataIdFromFilename(filename: str) -> tuple[str, int]: 

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) # type: str 

71 seqNum = int(mat.group(2)) # type: int 

72 return (dayObs, seqNum) 

73 

74 def getPreviousAnnotation(self, info: dict[tuple[str, int], str], imNum: int) -> str: 

75 if imNum == 0: 

76 raise RuntimeError("There is no previous annotation for the first image.") 

77 

78 previousFilename = self.fileList[imNum - 1] 

79 previousDataId = self._getDataIdFromFilename(previousFilename) 

80 previousAnnotation = info[previousDataId] 

81 return previousAnnotation 

82 

83 def addData(self, dataId, info, answer: str, mode: str, imNum: int) -> None: 

84 """Modes = O(verwrite), S(kip), A(ppend)""" 

85 if "=" in answer: 

86 answer = self.getPreviousAnnotation(info, imNum) 

87 

88 if dataId not in info: 

89 info[dataId] = answer 

90 return 

91 

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 

101 

102 @classmethod 

103 def loadAnnotations(cls, pickleFilename: str) -> tuple[dict, dict]: 

104 """Load back and split up annotations for easy use. 

105 

106 Anything after a space is returned as a whole string, 

107 anything before it is lower-cased and returned as tags. 

108 

109 from lsst.summit.extras import ImageSorter 

110 tags, notes = ImageSorter.loadAnnotations(pickleFilename) 

111 """ 

112 loaded = cls._load(pickleFilename) 

113 

114 tags, notes = {}, {} 

115 

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 

122 

123 if " " in answer: 

124 answer = answerFull.split()[0] 

125 notes[dataId] = " ".join([_ for _ in answerFull.split()[1:]]) 

126 tags[dataId] = answer.upper() 

127 

128 return tags, notes 

129 

130 @staticmethod 

131 def _load(filename: str): 

132 """Internal loading only. 

133 

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 

138 

139 @staticmethod 

140 def _save(info, filename: str) -> None: 

141 with open(filename, "wb") as dumpFile: 

142 pickle.dump(info, dumpFile) 

143 

144 def sortImages(self) -> dict | None: 

145 mode = "A" 

146 info = {} 

147 if os.path.exists(self.outputFilename): 

148 info = self._load(self.outputFilename) 

149 

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() 

159 

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 None # don't run twice in this case! 

172 

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) 

178 

179 plt.figure(figsize=(10, 10)) 

180 for imNum, filename in enumerate(self.fileList): 

181 info = self._load(self.outputFilename) 

182 

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 

187 

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) 

201 

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! 

209 

210 self.addData(dataId, info, answer, mode, imNum) 

211 self._save(info, self.outputFilename) 

212 

213 print(f"Info written to {self.outputFilename}") 

214 

215 return info 

216 

217 

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 ] 

226 

227 sorter = ImageSorter(fileList, "/Users/merlin/scratchfile.txt") 

228 sorter.sortImages()