Coverage for website/facedetection/models.py: 83.41%

219 statements  

« prev     ^ index     » next       coverage.py v7.6.7, created at 2025-08-14 10:31 +0000

1import os 

2from secrets import token_urlsafe 

3 

4from django.db import models 

5from django.db.models import Count, IntegerField, Value 

6from django.db.models.functions import Coalesce 

7 

8from queryable_properties.managers import QueryablePropertiesManager 

9from queryable_properties.properties import AnnotationProperty 

10from thumbnails.fields import ImageField 

11 

12from members.models import Member 

13from photos.models import Photo 

14 

15 

16class FaceDetectionUser(Member): 

17 class Meta: 

18 proxy = True 

19 

20 

21def secure_token() -> str: 

22 """Generate a 40 characters long base64 token suitable for authentication.""" 

23 return token_urlsafe(30) 

24 

25 

26def reference_face_uploadto(instance, filename): 

27 """Get path of file to upload to.""" 

28 random = token_urlsafe(6) 

29 extension = os.path.splitext(filename)[1] 

30 return os.path.join( 

31 "facedetection/reference-faces", 

32 f"{instance.user.username}-{random}{extension}", 

33 ) 

34 

35 

36class BaseFaceEncodingSource(models.Model): 

37 """Abstract model for a source of face encodings.""" 

38 

39 class Status(models.TextChoices): 

40 PROCESSING = "processing" 

41 DONE = "done" 

42 REJECTED = "rejected" 

43 

44 status = models.CharField( 

45 max_length=16, 

46 choices=Status.choices, 

47 default=Status.PROCESSING, 

48 help_text="Status of the encoding extraction process.", 

49 ) 

50 

51 token = models.CharField( 

52 max_length=40, 

53 default=secure_token, 

54 editable=False, 

55 help_text="Token used by a Lambda to authenticate " 

56 "to the API to submit encoding(s) for this source.", 

57 ) 

58 

59 submitted_at = models.DateTimeField( 

60 null=True, 

61 blank=True, 

62 help_text="The time at which the source was " 

63 "submitted to the Lambda for processing.", 

64 ) 

65 

66 class Meta: 

67 abstract = True 

68 

69 

70class FaceDetectionPhoto(BaseFaceEncodingSource): 

71 """A source of face encodings from a Photo.""" 

72 

73 photo = models.OneToOneField(Photo, on_delete=models.CASCADE) 

74 

75 num_faces = AnnotationProperty( 

76 Coalesce(Count("encodings"), Value(0), output__field=IntegerField()) 

77 ) 

78 

79 objects = QueryablePropertiesManager() 

80 

81 def __str__(self): 

82 return f"{self.photo.album} - {self.photo}" 

83 

84 

85class ReferenceFace(BaseFaceEncodingSource): 

86 """A source of face encodings from a reference photo of a user's face. 

87 

88 If a user marks a reference face for deletion, it will be kept for some 

89 time to allow us to monitor whether people searched for faces of others. 

90 """ 

91 

92 user = models.ForeignKey( 

93 FaceDetectionUser, 

94 on_delete=models.CASCADE, 

95 related_name="reference_faces", 

96 ) 

97 

98 file = ImageField(resize_source_to="source", upload_to=reference_face_uploadto) 

99 

100 created_at = models.DateTimeField(auto_now_add=True, editable=False) 

101 marked_for_deletion_at = models.DateTimeField(null=True, blank=True) 

102 

103 def delete(self, **kwargs): 

104 if self.file.name: 

105 self.file.delete() 

106 return super().delete(**kwargs) 

107 

108 def __str__(self): 

109 return f"Reference face {self.user.username} ({self.pk})" 

110 

111 

112class BaseFaceEncoding(models.Model): 

113 """Abstract model for a face encoding, without a source.""" 

114 

115 _field0 = models.FloatField() 

116 _field1 = models.FloatField() 

117 _field2 = models.FloatField() 

118 _field3 = models.FloatField() 

119 _field4 = models.FloatField() 

120 _field5 = models.FloatField() 

121 _field6 = models.FloatField() 

122 _field7 = models.FloatField() 

123 _field8 = models.FloatField() 

124 _field9 = models.FloatField() 

125 _field10 = models.FloatField() 

126 _field11 = models.FloatField() 

127 _field12 = models.FloatField() 

128 _field13 = models.FloatField() 

129 _field14 = models.FloatField() 

130 _field15 = models.FloatField() 

131 _field16 = models.FloatField() 

132 _field17 = models.FloatField() 

133 _field18 = models.FloatField() 

134 _field19 = models.FloatField() 

135 _field20 = models.FloatField() 

136 _field21 = models.FloatField() 

137 _field22 = models.FloatField() 

138 _field23 = models.FloatField() 

139 _field24 = models.FloatField() 

140 _field25 = models.FloatField() 

141 _field26 = models.FloatField() 

142 _field27 = models.FloatField() 

143 _field28 = models.FloatField() 

144 _field29 = models.FloatField() 

145 _field30 = models.FloatField() 

146 _field31 = models.FloatField() 

147 _field32 = models.FloatField() 

148 _field33 = models.FloatField() 

149 _field34 = models.FloatField() 

150 _field35 = models.FloatField() 

151 _field36 = models.FloatField() 

152 _field37 = models.FloatField() 

153 _field38 = models.FloatField() 

154 _field39 = models.FloatField() 

155 _field40 = models.FloatField() 

156 _field41 = models.FloatField() 

157 _field42 = models.FloatField() 

158 _field43 = models.FloatField() 

159 _field44 = models.FloatField() 

160 _field45 = models.FloatField() 

161 _field46 = models.FloatField() 

162 _field47 = models.FloatField() 

163 _field48 = models.FloatField() 

164 _field49 = models.FloatField() 

165 _field50 = models.FloatField() 

166 _field51 = models.FloatField() 

167 _field52 = models.FloatField() 

168 _field53 = models.FloatField() 

169 _field54 = models.FloatField() 

170 _field55 = models.FloatField() 

171 _field56 = models.FloatField() 

172 _field57 = models.FloatField() 

173 _field58 = models.FloatField() 

174 _field59 = models.FloatField() 

175 _field60 = models.FloatField() 

176 _field61 = models.FloatField() 

177 _field62 = models.FloatField() 

178 _field63 = models.FloatField() 

179 _field64 = models.FloatField() 

180 _field65 = models.FloatField() 

181 _field66 = models.FloatField() 

182 _field67 = models.FloatField() 

183 _field68 = models.FloatField() 

184 _field69 = models.FloatField() 

185 _field70 = models.FloatField() 

186 _field71 = models.FloatField() 

187 _field72 = models.FloatField() 

188 _field73 = models.FloatField() 

189 _field74 = models.FloatField() 

190 _field75 = models.FloatField() 

191 _field76 = models.FloatField() 

192 _field77 = models.FloatField() 

193 _field78 = models.FloatField() 

194 _field79 = models.FloatField() 

195 _field80 = models.FloatField() 

196 _field81 = models.FloatField() 

197 _field82 = models.FloatField() 

198 _field83 = models.FloatField() 

199 _field84 = models.FloatField() 

200 _field85 = models.FloatField() 

201 _field86 = models.FloatField() 

202 _field87 = models.FloatField() 

203 _field88 = models.FloatField() 

204 _field89 = models.FloatField() 

205 _field90 = models.FloatField() 

206 _field91 = models.FloatField() 

207 _field92 = models.FloatField() 

208 _field93 = models.FloatField() 

209 _field94 = models.FloatField() 

210 _field95 = models.FloatField() 

211 _field96 = models.FloatField() 

212 _field97 = models.FloatField() 

213 _field98 = models.FloatField() 

214 _field99 = models.FloatField() 

215 _field100 = models.FloatField() 

216 _field101 = models.FloatField() 

217 _field102 = models.FloatField() 

218 _field103 = models.FloatField() 

219 _field104 = models.FloatField() 

220 _field105 = models.FloatField() 

221 _field106 = models.FloatField() 

222 _field107 = models.FloatField() 

223 _field108 = models.FloatField() 

224 _field109 = models.FloatField() 

225 _field110 = models.FloatField() 

226 _field111 = models.FloatField() 

227 _field112 = models.FloatField() 

228 _field113 = models.FloatField() 

229 _field114 = models.FloatField() 

230 _field115 = models.FloatField() 

231 _field116 = models.FloatField() 

232 _field117 = models.FloatField() 

233 _field118 = models.FloatField() 

234 _field119 = models.FloatField() 

235 _field120 = models.FloatField() 

236 _field121 = models.FloatField() 

237 _field122 = models.FloatField() 

238 _field123 = models.FloatField() 

239 _field124 = models.FloatField() 

240 _field125 = models.FloatField() 

241 _field126 = models.FloatField() 

242 _field127 = models.FloatField() 

243 

244 class Meta: 

245 abstract = True 

246 

247 @property 

248 def encoding(self) -> list[float]: 

249 if hasattr(self, "_encoding"): 

250 return self._encoding 

251 

252 self._encoding = [getattr(self, f"_field{i}") for i in range(128)] 

253 return self._encoding 

254 

255 @encoding.setter 

256 def encoding(self, value): 

257 self._encoding = value 

258 for i in range(128): 

259 setattr(self, f"_field{i}", value[i]) 

260 

261 def encoding_match_function(self) -> str: 

262 """Return a SQL expression that holds for encodings that match this one. 

263 

264 Computes the Euclidean distance between this encoding and the other one, 

265 and checks whether it's less than a threshold of 0.49. 

266 """ 

267 sum_of_squares = " + ".join( 

268 f"power(_field{i} - {self.encoding[i]}, 2)" for i in range(128) 

269 ) 

270 euclidean_distance = f"sqrt({sum_of_squares})" 

271 

272 return f"{euclidean_distance} < 0.49" 

273 

274 

275class PhotoFaceEncoding(BaseFaceEncoding): 

276 """A face encoding found in a Photo.""" 

277 

278 photo = models.ForeignKey( 

279 FaceDetectionPhoto, on_delete=models.CASCADE, related_name="encodings" 

280 ) 

281 

282 def __str__(self) -> str: 

283 return f"Face in {self.photo} ({self.pk})" 

284 

285 def save(self, **kwargs): 

286 created = self.pk is None 

287 super().save(**kwargs) 

288 

289 if created: 

290 self._set_matches() 

291 

292 def _set_matches(self): 

293 """(Re-)compute the reference encodings that match this face.""" 

294 matches = ReferenceFaceEncoding.objects.extra( 

295 where=[self.encoding_match_function()] 

296 ) 

297 self.matches.set(matches) 

298 

299 

300class ReferenceFaceEncoding(BaseFaceEncoding): 

301 """The face encoding in a reference photo.""" 

302 

303 reference = models.OneToOneField(ReferenceFace, on_delete=models.CASCADE) 

304 

305 matches = models.ManyToManyField( 

306 PhotoFaceEncoding, 

307 related_name="matches", 

308 editable=False, 

309 ) 

310 

311 num_matches = AnnotationProperty( 

312 Coalesce(Count("matches"), Value(0), output__field=IntegerField()) 

313 ) 

314 

315 objects = QueryablePropertiesManager() 

316 

317 def __str__(self) -> str: 

318 return f"Encoding for {self.reference}" 

319 

320 def save(self, **kwargs): 

321 created = self.pk is None 

322 super().save(**kwargs) 

323 

324 if created: 

325 self._set_matches() 

326 

327 def _set_matches(self): 

328 """(Re-)compute the photo face encodings that match this reference.""" 

329 matches = PhotoFaceEncoding.objects.extra( 

330 where=[self.encoding_match_function()] 

331 ) 

332 self.matches.set(matches)