models.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals, division, print_function
  3. import os
  4. from math import radians, degrees, sin, cos, asin, atan2, sqrt, ceil
  5. from django.db import models
  6. from django.conf import settings
  7. from django.core.exceptions import ValidationError
  8. from django.core.validators import MinValueValidator, MaxValueValidator
  9. from django.core.urlresolvers import reverse
  10. from django.utils.encoding import python_2_unicode_compatible
  11. from django.utils.timezone import now
  12. from django.utils.translation import ugettext_lazy as _
  13. from .tasks import generate_tiles
  14. from .utils import makedirs, path_exists
  15. EARTH_RADIUS = 6371009
  16. class Point(models.Model):
  17. """Geographical point, with altitude."""
  18. latitude = models.FloatField(verbose_name=_("latitude"), help_text=_("In degrees"),
  19. validators=[MinValueValidator(-90),
  20. MaxValueValidator(90)])
  21. longitude = models.FloatField(verbose_name=_("longitude"), help_text=_("In degrees"),
  22. validators=[MinValueValidator(-180),
  23. MaxValueValidator(180)])
  24. ground_altitude = models.FloatField(verbose_name=_("altitude at ground level"),
  25. help_text=_("In meters"),
  26. validators=[MinValueValidator(0.)])
  27. height_above_ground = models.FloatField(verbose_name=_("height above ground"),
  28. help_text=_("In meters"),
  29. default=0.)
  30. @property
  31. def altitude(self):
  32. return self.ground_altitude + self.height_above_ground
  33. @property
  34. def latitude_rad(self):
  35. return radians(self.latitude)
  36. @property
  37. def longitude_rad(self):
  38. return radians(self.longitude)
  39. @property
  40. def altitude_abs(self):
  41. """Absolute distance to the center of Earth (in a spherical model)"""
  42. return EARTH_RADIUS + self.altitude
  43. def great_angle(self, other):
  44. """Returns the great angle, in radians, between the two given points. The
  45. great angle is the angle formed by the two points when viewed from
  46. the center of the Earth.
  47. """
  48. lon_delta = other.longitude_rad - self.longitude_rad
  49. a = (cos(other.latitude_rad) * sin(lon_delta)) ** 2 \
  50. + (cos(self.latitude_rad) * sin(other.latitude_rad) \
  51. - sin(self.latitude_rad) * cos(other.latitude_rad) * cos(lon_delta)) ** 2
  52. b = sin(self.latitude_rad) * sin(other.latitude_rad) \
  53. + cos(self.latitude_rad) * cos(other.latitude_rad) * cos(lon_delta)
  54. angle = atan2(sqrt(a), b)
  55. return angle
  56. def great_circle_distance(self, other):
  57. """Returns the great circle distance between two points, without taking
  58. into account their altitude. Don't use this to compute
  59. line-of-sight distance, see [line_distance] instead.
  60. """
  61. return EARTH_RADIUS * self.great_angle(other)
  62. def line_distance(self, other):
  63. """Distance of the straight line between two points on Earth, in meters.
  64. Note that this is only useful because we are considering
  65. line-of-sight links, where straight-line distance is the relevant
  66. distance. For arbitrary points on Earth, great-circle distance
  67. would most likely be preferred.
  68. """
  69. delta_lon = other.longitude_rad - self.longitude_rad
  70. # Cosine of the angle between the two points on their great circle.
  71. cos_angle = sin(self.latitude_rad) * sin(other.latitude_rad) \
  72. + cos(self.latitude_rad) * cos(other.latitude_rad) * cos(delta_lon)
  73. # Al-Kashi formula
  74. return sqrt(self.altitude_abs ** 2 \
  75. + other.altitude_abs ** 2 \
  76. - 2 * self.altitude_abs * other.altitude_abs * cos_angle)
  77. def bearing(self, other):
  78. """Bearing, in degrees, between this point and another point."""
  79. delta_lon = other.longitude_rad - self.longitude_rad
  80. y = sin(delta_lon) * cos(other.latitude_rad)
  81. x = cos(self.latitude_rad) * sin(other.latitude_rad) \
  82. - sin(self.latitude_rad) * cos(other.latitude_rad) * cos(delta_lon)
  83. return degrees(atan2(y, x))
  84. def elevation(self, other):
  85. """Elevation, in degrees, between this point and another point."""
  86. d = self.line_distance(other)
  87. sin_elev = (other.altitude_abs ** 2 - self.altitude_abs ** 2 - d ** 2) \
  88. / (2 * self.altitude_abs * d)
  89. # Ensure the value belongs to [-1, 1], which might not be the case because of float rouding
  90. sin_elev = max(min(sin_elev, 1), -1)
  91. return degrees(asin(sin_elev))
  92. class Meta:
  93. abstract = True
  94. @python_2_unicode_compatible
  95. class ReferencePoint(Point):
  96. """Point of Interest (POI): can either be a prospective subscriber, a
  97. current subscriber, or just a reference point such as a big building
  98. or a mountain.
  99. """
  100. name = models.CharField(verbose_name=_("name"), max_length=255,
  101. help_text=_("Name of the point"))
  102. KIND_OTHER = 'other'
  103. KIND_SUBSCRIBER = 'subscriber'
  104. KIND_WAITING = 'waiting'
  105. KIND_CHOICES = (
  106. (KIND_WAITING, _('waiting')),
  107. (KIND_SUBSCRIBER, _('subscriber')),
  108. (KIND_OTHER, _('other')),
  109. )
  110. kind = models.CharField(verbose_name=_('kind'), max_length=255,
  111. choices=KIND_CHOICES, default=KIND_WAITING)
  112. DURATION_UNKNOWN = 'unknown'
  113. DURATION_SHORT = 'short'
  114. DURATION_MEDIUM = 'medium'
  115. DURATION_LONG = 'long'
  116. DURATION_CHOICES = (
  117. (DURATION_UNKNOWN, _('unknown')),
  118. (DURATION_SHORT, _('short')),
  119. (DURATION_MEDIUM, _('medium')),
  120. (DURATION_LONG, _('long')),
  121. )
  122. duration = models.CharField(verbose_name=_('duration'),
  123. help_text=_('How long is this point expected to be used'),
  124. max_length=255,
  125. choices=DURATION_CHOICES,
  126. default=DURATION_UNKNOWN)
  127. duration_updated = models.DateTimeField(default=now,
  128. verbose_name=_("last updated"))
  129. def __str__(self):
  130. return self.name
  131. def save(self, *args, **kwargs):
  132. '''On save, update timestamp if duration has changed'''
  133. try:
  134. obj = ReferencePoint.objects.get(pk=self.pk)
  135. except ReferencePoint.DoesNotExist:
  136. return super(ReferencePoint, self).save(*args, **kwargs)
  137. if obj.duration != self.duration:
  138. self.duration_updated = now()
  139. return super(ReferencePoint, self).save(*args, **kwargs)
  140. class Meta:
  141. verbose_name = _("reference point")
  142. verbose_name_plural = _("reference points")
  143. class Panorama(ReferencePoint):
  144. loop = models.BooleanField(default=False, verbose_name=_("360° panorama"),
  145. help_text=_("Whether the panorama loops around the edges"))
  146. image = models.ImageField(verbose_name=_("image"), upload_to="pano",
  147. width_field="image_width",
  148. height_field="image_height")
  149. image_width = models.PositiveIntegerField(default=0, verbose_name=_("image width"))
  150. image_height = models.PositiveIntegerField(default=0, verbose_name=_("image height"))
  151. # Set of references, i.e. reference points with information on how
  152. # they relate to this panorama.
  153. references = models.ManyToManyField(ReferencePoint, through='Reference',
  154. related_name="referenced_panorama",
  155. verbose_name=_("references"))
  156. def tiles_dir(self):
  157. return os.path.join(settings.MEDIA_ROOT, settings.PANORAMA_TILES_DIR,
  158. str(self.pk))
  159. def tiles_url(self):
  160. return os.path.join(settings.MEDIA_URL, settings.PANORAMA_TILES_DIR,
  161. str(self.pk))
  162. def has_tiles(self):
  163. return path_exists(self.tiles_dir()) and len(os.listdir(self.tiles_dir())) > 0
  164. has_tiles.boolean = True
  165. has_tiles.short_description = _("Tiles available?")
  166. def delete_tiles(self):
  167. """Delete all tiles and the tiles dir"""
  168. # If the directory doesn't exist, do nothing
  169. if not path_exists(self.tiles_dir()):
  170. return
  171. # Delete all tiles
  172. for filename in os.listdir(self.tiles_dir()):
  173. os.unlink(os.path.join(self.tiles_dir(), filename))
  174. os.rmdir(self.tiles_dir())
  175. def generate_tiles(self):
  176. makedirs(self.tiles_dir(), exist_ok=True)
  177. generate_tiles.delay(self.image.path, self.tiles_dir())
  178. def get_absolute_url(self, cap=None, ele=None, zoom=None):
  179. base_url = reverse('panorama:view_pano', args=[str(self.pk)])
  180. # Add parameters to point to the given direction, interpreted by
  181. # the js frontend
  182. if zoom is None:
  183. zoom = 0
  184. if not None in (zoom, cap, ele):
  185. return base_url + "#zoom={}/cap={}/ele={}".format(zoom, cap, ele)
  186. else:
  187. return base_url
  188. def tiles_data(self):
  189. """Hack to feed the current js code with tiles data (we should use the
  190. JSON API instead, and get rid of this function)"""
  191. data = dict()
  192. for zoomlevel in range(9):
  193. width = self.image_width >> zoomlevel
  194. height = self.image_height >> zoomlevel
  195. d = dict()
  196. d["tile_width"] = d["tile_height"] = 256
  197. # Python3-style division
  198. d["ntiles_x"] = int(ceil(width / 256))
  199. d["ntiles_y"] = int(ceil(height / 256))
  200. d["last_tile_width"] = width % 256
  201. d["last_tile_height"] = height % 256
  202. data[zoomlevel] = d
  203. return data
  204. def refpoints_data(self):
  205. """Similar hack, returns all reference points around the panorama."""
  206. def get_url(refpoint):
  207. """If the refpoint is also a panorama, returns its canonical URL"""
  208. if hasattr(refpoint, "panorama"):
  209. # Point towards the current panorama
  210. return refpoint.panorama.get_absolute_url(refpoint.bearing(self),
  211. refpoint.elevation(self))
  212. else:
  213. return ""
  214. refpoints = [refpoint for refpoint in ReferencePoint.objects.all()
  215. if self.great_circle_distance(refpoint) <= settings.PANORAMA_MAX_DISTANCE and refpoint.pk != self.pk]
  216. refpoints.sort(key=lambda r: r.name)
  217. return enumerate([{"id": r.pk,
  218. "name": r.name,
  219. "url": get_url(r),
  220. "cap": self.bearing(r),
  221. "elevation": self.elevation(r),
  222. "elevation_ground": self.elevation(Point(latitude=r.latitude, longitude=r.longitude, ground_altitude=r.ground_altitude, height_above_ground=0)),
  223. "distance": self.line_distance(r)}
  224. for r in refpoints])
  225. def references_data(self):
  226. """Similar hack, returns all references currently associated to the
  227. panorama."""
  228. return [{"id": r.pk,
  229. "name": r.reference_point.name,
  230. # Adapt to js-based coordinates (x between 0 and 1, y
  231. # between -0.5 and 0.5)
  232. "x": r.x / r.panorama.image_width,
  233. "y": (r.y / r.panorama.image_height) - 0.5,
  234. "cap": self.bearing(r.reference_point),
  235. "elevation": self.elevation(r.reference_point)}
  236. for r in self.panorama_references.all()]
  237. def is_visible(self, point):
  238. """Return True if the Panorama can see the point."""
  239. if self.great_circle_distance(point) > settings.PANORAMA_MAX_DISTANCE:
  240. return False
  241. if self.loop:
  242. return True
  243. cap = self.bearing(point) % 360
  244. cap_min = self.cap_min()
  245. cap_max = self.cap_max()
  246. # Not enough references
  247. if cap_min is None or cap_max is None:
  248. return False
  249. if cap_min < cap_max:
  250. # Nominal case
  251. return cap_min <= cap <= cap_max
  252. else:
  253. return cap_min <= cap or cap <= cap_max
  254. def cap_min(self):
  255. return self._cap_minmax(True)
  256. def cap_max(self):
  257. return self._cap_minmax(False)
  258. def _cap_minmax(self, ismin=True):
  259. """Return the cap on the border of the image.
  260. :param ismin: True if the min cap should be processed False if it is the
  261. max.
  262. @return None if the image is looping or if the image have less than two
  263. references.
  264. """
  265. if self.loop:
  266. return None
  267. it = self.panorama_references.order_by(
  268. 'x' if ismin else '-x').iterator()
  269. try:
  270. ref1 = next(it)
  271. ref2 = next(it)
  272. except StopIteration:
  273. return None
  274. cap1 = self.bearing(ref1.reference_point)
  275. cap2 = self.bearing(ref2.reference_point)
  276. target_x = 0 if ismin else self.image_width
  277. # For circulary issues
  278. if ismin and cap2 < cap1:
  279. cap2 += 360
  280. if (not ismin) and cap1 < cap2:
  281. cap1 += 360
  282. target_cap = cap1 + (target_x - ref1.x) * (cap2 - cap1) / \
  283. (ref2.x - ref1.x)
  284. return target_cap % 360
  285. class Meta:
  286. verbose_name = _("panorama")
  287. verbose_name_plural = _("panoramas")
  288. @python_2_unicode_compatible
  289. class Reference(models.Model):
  290. """A reference is made of a Panorama, a Reference Point, and the position
  291. (x, y) of the reference point inside the image. With enough
  292. references, the panorama is calibrated. That is, we can build a
  293. mapping between pixels of the image and directions in 3D space, which
  294. are represented by (azimuth, elevation) couples."""
  295. # Components of the ManyToMany relation
  296. reference_point = models.ForeignKey(ReferencePoint, related_name="refpoint_references",
  297. verbose_name=_("reference point"))
  298. panorama = models.ForeignKey(Panorama, related_name="panorama_references",
  299. verbose_name=_("panorama"))
  300. # Position of the reference point in the panorama image
  301. x = models.PositiveIntegerField()
  302. y = models.PositiveIntegerField()
  303. class Meta:
  304. # It makes no sense to have multiple references of the same
  305. # reference point on a given panorama.
  306. unique_together = (("reference_point", "panorama"),)
  307. verbose_name = _("reference")
  308. verbose_name_plural = _("references")
  309. def clean(self):
  310. # Check that the reference point and the panorama are different
  311. # (remember that panoramas can *also* be seen as reference points)
  312. if self.panorama.pk == self.reference_point.pk:
  313. raise ValidationError(_("A panorama can't reference itself."))
  314. # Check than the position is within the bounds of the image.
  315. w = self.panorama.image_width
  316. h = self.panorama.image_height
  317. if self.x >= w or self.y >= h:
  318. raise ValidationError(_("Position {xy} is outside the bounds "
  319. "of the image ({width}, {height}).").format(
  320. xy=(self.x, self.y),
  321. width=w,
  322. height=h))
  323. def __str__(self):
  324. return _('{refpoint} at {xy} in {pano}').format(
  325. pano=self.panorama.name,
  326. xy=(self.x, self.y),
  327. refpoint=self.reference_point.name,
  328. )