models.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals, division, print_function
  3. import subprocess
  4. import os
  5. from math import radians, degrees, sin, cos, asin, atan2, sqrt, ceil
  6. from django.db import models
  7. from django.conf import settings
  8. from django.core.exceptions import ValidationError
  9. from django.core.validators import MinValueValidator, MaxValueValidator
  10. from django.core.urlresolvers import reverse
  11. from django.utils.encoding import python_2_unicode_compatible
  12. from .tasks import generate_tiles, panorama_pipeline
  13. from .utils import makedirs, path_exists
  14. EARTH_RADIUS = 6371009
  15. class Point(models.Model):
  16. """Geographical point, with altitude."""
  17. latitude = models.FloatField(verbose_name="latitude", help_text="In degrees",
  18. validators=[MinValueValidator(-90),
  19. MaxValueValidator(90)])
  20. longitude = models.FloatField(verbose_name="longitude", help_text="In degrees",
  21. validators=[MinValueValidator(-180),
  22. MaxValueValidator(180)])
  23. altitude = models.FloatField(verbose_name="altitude", help_text="In meters",
  24. validators=[MinValueValidator(0.)])
  25. @property
  26. def latitude_rad(self):
  27. return radians(self.latitude)
  28. @property
  29. def longitude_rad(self):
  30. return radians(self.longitude)
  31. @property
  32. def altitude_abs(self):
  33. """Absolute distance to the center of Earth (in a spherical model)"""
  34. return EARTH_RADIUS + self.altitude
  35. def great_angle(self, other):
  36. """Returns the great angle, in radians, between the two given points. The
  37. great angle is the angle formed by the two points when viewed from
  38. the center of the Earth.
  39. """
  40. lon_delta = other.longitude_rad - self.longitude_rad
  41. a = (cos(other.latitude_rad) * sin(lon_delta)) ** 2 \
  42. + (cos(self.latitude_rad) * sin(other.latitude_rad) \
  43. - sin(self.latitude_rad) * cos(other.latitude_rad) * cos(lon_delta)) ** 2
  44. b = sin(self.latitude_rad) * sin(other.latitude_rad) \
  45. + cos(self.latitude_rad) * cos(other.latitude_rad) * cos(lon_delta)
  46. angle = atan2(sqrt(a), b)
  47. return angle
  48. def great_circle_distance(self, other):
  49. """Returns the great circle distance between two points, without taking
  50. into account their altitude. Don't use this to compute
  51. line-of-sight distance, see [line_distance] instead.
  52. """
  53. return EARTH_RADIUS * self.great_angle(other)
  54. def line_distance(self, other):
  55. """Distance of the straight line between two points on Earth, in meters.
  56. Note that this is only useful because we are considering
  57. line-of-sight links, where straight-line distance is the relevant
  58. distance. For arbitrary points on Earth, great-circle distance
  59. would most likely be preferred.
  60. """
  61. delta_lon = other.longitude_rad - self.longitude_rad
  62. # Cosine of the angle between the two points on their great circle.
  63. cos_angle = sin(self.latitude_rad) * sin(other.latitude_rad) \
  64. + cos(self.latitude_rad) * cos(other.latitude_rad) * cos(delta_lon)
  65. # Al-Kashi formula
  66. return sqrt(self.altitude_abs ** 2 \
  67. + other.altitude_abs ** 2 \
  68. - 2 * self.altitude_abs * other.altitude_abs * cos_angle)
  69. def bearing(self, other):
  70. """Bearing, in degrees, between this point and another point."""
  71. delta_lon = other.longitude_rad - self.longitude_rad
  72. y = sin(delta_lon) * cos(other.latitude_rad)
  73. x = cos(self.latitude_rad) * sin(other.latitude_rad) \
  74. - sin(self.latitude_rad) * cos(other.latitude_rad) * cos(delta_lon)
  75. return degrees(atan2(y, x))
  76. def elevation(self, other):
  77. """Elevation, in degrees, between this point and another point."""
  78. d = self.line_distance(other)
  79. sin_elev = (other.altitude_abs ** 2 - self.altitude_abs ** 2 - d ** 2) \
  80. / (2 * self.altitude_abs * d)
  81. return degrees(asin(sin_elev))
  82. class Meta:
  83. abstract = True
  84. @python_2_unicode_compatible
  85. class ReferencePoint(Point):
  86. """Reference point, to be used"""
  87. name = models.CharField(verbose_name="name", max_length=255,
  88. help_text="Name of the point")
  89. def __str__(self):
  90. return "Reference point : " + self.name
  91. @python_2_unicode_compatible
  92. class Panorama(ReferencePoint):
  93. loop = models.BooleanField(default=False, verbose_name="360° panorama",
  94. help_text="Whether the panorama loops around the edges")
  95. # Panorama
  96. image = models.ImageField(verbose_name="image", upload_to="pano",
  97. width_field="image_width",
  98. height_field="image_height")
  99. image_width = models.PositiveIntegerField(default=0)
  100. image_height = models.PositiveIntegerField(default=0)
  101. # Set of references, i.e. reference points with information on how
  102. # they relate to this panorama.
  103. references = models.ManyToManyField(ReferencePoint, through='Reference',
  104. related_name="referenced_panorama")
  105. def tiles_dir(self):
  106. return os.path.join(settings.MEDIA_ROOT, settings.PANORAMA_TILES_DIR,
  107. str(self.pk))
  108. def tiles_url(self):
  109. return os.path.join(settings.MEDIA_URL, settings.PANORAMA_TILES_DIR,
  110. str(self.pk))
  111. def has_tiles(self):
  112. return path_exists(self.tiles_dir()) and len(os.listdir(self.tiles_dir())) > 0
  113. has_tiles.boolean = True
  114. def delete_tiles(self):
  115. """Delete all tiles and the tiles dir"""
  116. # If the directory doesn't exist, do nothing
  117. if not path_exists(self.tiles_dir()):
  118. return
  119. # Delete all tiles
  120. for filename in os.listdir(self.tiles_dir()):
  121. os.unlink(os.path.join(self.tiles_dir(), filename))
  122. os.rmdir(self.tiles_dir())
  123. def generate_tiles(self):
  124. makedirs(self.tiles_dir(), exist_ok=True)
  125. generate_tiles.delay(self.image.path, self.tiles_dir())
  126. def generate_panorama(self):
  127. output = os.path.join(settings.MEDIA_ROOT, "pano", "{}.tif".format(self.id))
  128. panorama_pipeline.delay([p.photo.path for p in self.photos.all()],
  129. output)
  130. def get_absolute_url(self):
  131. return reverse('panorama:view_pano', args=[str(self.pk)])
  132. def tiles_data(self):
  133. """Hack to feed the current js code with tiles data (we should use the
  134. JSON API instead, and get rid of this function)"""
  135. data = dict()
  136. for zoomlevel in range(9):
  137. width = self.image_width >> zoomlevel
  138. height = self.image_height >> zoomlevel
  139. d = dict()
  140. d["tile_width"] = d["tile_height"] = 256
  141. # Python3-style division
  142. d["ntiles_x"] = int(ceil(width / 256))
  143. d["ntiles_y"] = int(ceil(height / 256))
  144. d["last_tile_width"] = width % 256
  145. d["last_tile_height"] = height % 256
  146. data[zoomlevel] = d
  147. return data
  148. def refpoints_data(self):
  149. """Similar hack, returns all reference points around the panorama."""
  150. refpoints = [refpoint for refpoint in ReferencePoint.objects.all()
  151. if self.great_circle_distance(refpoint) <= settings.PANORAMA_MAX_DISTANCE and refpoint.pk != self.pk]
  152. return enumerate([{"name": r.name,
  153. "cap": self.bearing(r),
  154. "elevation": self.elevation(r),
  155. "distance": self.line_distance(r) / 1000}
  156. for r in refpoints])
  157. def references_data(self):
  158. """Similar hack, returns all references currently associated to the
  159. panorama."""
  160. return [{"name": r.reference_point.name,
  161. "x": r.x,
  162. "y": r.y,
  163. "cap": self.bearing(r.reference_point),
  164. "elevation": self.elevation(r.reference_point)}
  165. for r in self.panorama_references.all()]
  166. def __str__(self):
  167. return "Panorama : " + self.name
  168. @python_2_unicode_compatible
  169. class PanoramaPhoto(models.Model):
  170. """Photo used to assemble a panorama automatically."""
  171. panorama = models.ForeignKey(Panorama, verbose_name="panorama", related_name="photos")
  172. # Maybe use a lambda for upload_to, to upload to "photos/<panorama_id>/"
  173. photo = models.ImageField(verbose_name="photo", upload_to="photos")
  174. def __str__(self):
  175. return "{} ({})".format(self.panorama.name, self.photo.name)
  176. class Reference(models.Model):
  177. """A reference is made of a Panorama, a Reference Point, and the position
  178. (x, y) of the reference point inside the image. With enough
  179. references, the panorama is calibrated. That is, we can build a
  180. mapping between pixels of the image and directions in 3D space, which
  181. are represented by (azimuth, elevation) couples."""
  182. # Components of the ManyToMany relation
  183. reference_point = models.ForeignKey(ReferencePoint, related_name="refpoint_references")
  184. panorama = models.ForeignKey(Panorama, related_name="panorama_references")
  185. # Position of the reference point in the panorama image
  186. x = models.PositiveIntegerField()
  187. y = models.PositiveIntegerField()
  188. class Meta:
  189. # It makes no sense to have multiple references of the same
  190. # reference point on a given panorama.
  191. unique_together = (("reference_point", "panorama"),)
  192. def clean(self):
  193. # Check that the reference point and the panorama are different
  194. # (remember that panoramas can *also* be seen as reference points)
  195. if self.panorama.pk == self.reference_point.pk:
  196. raise ValidationError("A panorama can't reference itself.")
  197. # Check than the position is within the bounds of the image.
  198. w = self.panorama.image_width
  199. h = self.panorama.image_height
  200. if self.x >= w or self.y >= h:
  201. raise ValidationError("Position ({x}, {y}) is outside the bounds "
  202. "of the image ({width}, {height}).".format(
  203. x=self.x,
  204. y=self.y,
  205. width=w,
  206. height=h))