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
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1import os
2from secrets import token_urlsafe
4from django.db import models
5from django.db.models import Count, IntegerField, Value
6from django.db.models.functions import Coalesce
8from queryable_properties.managers import QueryablePropertiesManager
9from queryable_properties.properties import AnnotationProperty
10from thumbnails.fields import ImageField
12from members.models import Member
13from photos.models import Photo
16class FaceDetectionUser(Member):
17 class Meta:
18 proxy = True
21def secure_token() -> str:
22 """Generate a 40 characters long base64 token suitable for authentication."""
23 return token_urlsafe(30)
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 )
36class BaseFaceEncodingSource(models.Model):
37 """Abstract model for a source of face encodings."""
39 class Status(models.TextChoices):
40 PROCESSING = "processing"
41 DONE = "done"
42 REJECTED = "rejected"
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 )
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 )
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 )
66 class Meta:
67 abstract = True
70class FaceDetectionPhoto(BaseFaceEncodingSource):
71 """A source of face encodings from a Photo."""
73 photo = models.OneToOneField(Photo, on_delete=models.CASCADE)
75 num_faces = AnnotationProperty(
76 Coalesce(Count("encodings"), Value(0), output__field=IntegerField())
77 )
79 objects = QueryablePropertiesManager()
81 def __str__(self):
82 return f"{self.photo.album} - {self.photo}"
85class ReferenceFace(BaseFaceEncodingSource):
86 """A source of face encodings from a reference photo of a user's face.
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 """
92 user = models.ForeignKey(
93 FaceDetectionUser,
94 on_delete=models.CASCADE,
95 related_name="reference_faces",
96 )
98 file = ImageField(resize_source_to="source", upload_to=reference_face_uploadto)
100 created_at = models.DateTimeField(auto_now_add=True, editable=False)
101 marked_for_deletion_at = models.DateTimeField(null=True, blank=True)
103 def delete(self, **kwargs):
104 if self.file.name:
105 self.file.delete()
106 return super().delete(**kwargs)
108 def __str__(self):
109 return f"Reference face {self.user.username} ({self.pk})"
112class BaseFaceEncoding(models.Model):
113 """Abstract model for a face encoding, without a source."""
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()
244 class Meta:
245 abstract = True
247 @property
248 def encoding(self) -> list[float]:
249 if hasattr(self, "_encoding"):
250 return self._encoding
252 self._encoding = [getattr(self, f"_field{i}") for i in range(128)]
253 return self._encoding
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])
261 def encoding_match_function(self) -> str:
262 """Return a SQL expression that holds for encodings that match this one.
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})"
272 return f"{euclidean_distance} < 0.49"
275class PhotoFaceEncoding(BaseFaceEncoding):
276 """A face encoding found in a Photo."""
278 photo = models.ForeignKey(
279 FaceDetectionPhoto, on_delete=models.CASCADE, related_name="encodings"
280 )
282 def __str__(self) -> str:
283 return f"Face in {self.photo} ({self.pk})"
285 def save(self, **kwargs):
286 created = self.pk is None
287 super().save(**kwargs)
289 if created:
290 self._set_matches()
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)
300class ReferenceFaceEncoding(BaseFaceEncoding):
301 """The face encoding in a reference photo."""
303 reference = models.OneToOneField(ReferenceFace, on_delete=models.CASCADE)
305 matches = models.ManyToManyField(
306 PhotoFaceEncoding,
307 related_name="matches",
308 editable=False,
309 )
311 num_matches = AnnotationProperty(
312 Coalesce(Count("matches"), Value(0), output__field=IntegerField())
313 )
315 objects = QueryablePropertiesManager()
317 def __str__(self) -> str:
318 return f"Encoding for {self.reference}"
320 def save(self, **kwargs):
321 created = self.pk is None
322 super().save(**kwargs)
324 if created:
325 self._set_matches()
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)