123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- # -*- coding: utf-8 -*-
- from __future__ import unicode_literals, division, print_function
- import os
- from math import radians, degrees, sin, cos, asin, atan2, sqrt, ceil
- from django.db import models
- from django.conf import settings
- from django.core.exceptions import ValidationError
- from django.core.validators import MinValueValidator, MaxValueValidator
- from django.core.urlresolvers import reverse
- from django.utils.encoding import python_2_unicode_compatible
- from django.utils.translation import ugettext_lazy as _
- from .tasks import generate_tiles
- from .utils import makedirs, path_exists
- EARTH_RADIUS = 6371009
- class Point(models.Model):
- """Geographical point, with altitude."""
- latitude = models.FloatField(verbose_name=_("latitude"), help_text=_("In degrees"),
- validators=[MinValueValidator(-90),
- MaxValueValidator(90)])
- longitude = models.FloatField(verbose_name=_("longitude"), help_text=_("In degrees"),
- validators=[MinValueValidator(-180),
- MaxValueValidator(180)])
- altitude = models.FloatField(verbose_name=_("altitude"), help_text=_("In meters"),
- validators=[MinValueValidator(0.)])
- @property
- def latitude_rad(self):
- return radians(self.latitude)
- @property
- def longitude_rad(self):
- return radians(self.longitude)
- @property
- def altitude_abs(self):
- """Absolute distance to the center of Earth (in a spherical model)"""
- return EARTH_RADIUS + self.altitude
- def great_angle(self, other):
- """Returns the great angle, in radians, between the two given points. The
- great angle is the angle formed by the two points when viewed from
- the center of the Earth.
- """
- lon_delta = other.longitude_rad - self.longitude_rad
- a = (cos(other.latitude_rad) * sin(lon_delta)) ** 2 \
- + (cos(self.latitude_rad) * sin(other.latitude_rad) \
- - sin(self.latitude_rad) * cos(other.latitude_rad) * cos(lon_delta)) ** 2
- b = sin(self.latitude_rad) * sin(other.latitude_rad) \
- + cos(self.latitude_rad) * cos(other.latitude_rad) * cos(lon_delta)
- angle = atan2(sqrt(a), b)
- return angle
- def great_circle_distance(self, other):
- """Returns the great circle distance between two points, without taking
- into account their altitude. Don't use this to compute
- line-of-sight distance, see [line_distance] instead.
- """
- return EARTH_RADIUS * self.great_angle(other)
- def line_distance(self, other):
- """Distance of the straight line between two points on Earth, in meters.
- Note that this is only useful because we are considering
- line-of-sight links, where straight-line distance is the relevant
- distance. For arbitrary points on Earth, great-circle distance
- would most likely be preferred.
- """
- delta_lon = other.longitude_rad - self.longitude_rad
- # Cosine of the angle between the two points on their great circle.
- cos_angle = sin(self.latitude_rad) * sin(other.latitude_rad) \
- + cos(self.latitude_rad) * cos(other.latitude_rad) * cos(delta_lon)
- # Al-Kashi formula
- return sqrt(self.altitude_abs ** 2 \
- + other.altitude_abs ** 2 \
- - 2 * self.altitude_abs * other.altitude_abs * cos_angle)
- def bearing(self, other):
- """Bearing, in degrees, between this point and another point."""
- delta_lon = other.longitude_rad - self.longitude_rad
- y = sin(delta_lon) * cos(other.latitude_rad)
- x = cos(self.latitude_rad) * sin(other.latitude_rad) \
- - sin(self.latitude_rad) * cos(other.latitude_rad) * cos(delta_lon)
- return degrees(atan2(y, x))
- def elevation(self, other):
- """Elevation, in degrees, between this point and another point."""
- d = self.line_distance(other)
- sin_elev = (other.altitude_abs ** 2 - self.altitude_abs ** 2 - d ** 2) \
- / (2 * self.altitude_abs * d)
- return degrees(asin(sin_elev))
- class Meta:
- abstract = True
- @python_2_unicode_compatible
- class ReferencePoint(Point):
- """Reference point, to be used"""
- name = models.CharField(verbose_name=_("name"), max_length=255,
- help_text=_("Name of the point"))
- KIND_OTHER = 'other'
- KIND_SUBSCRIBER = 'subscriber'
- KIND_WAITING = 'waiting'
- KIND_CHOICES = (
- (KIND_WAITING, _('waiting')),
- (KIND_SUBSCRIBER, _('subscriber')),
- (KIND_OTHER, _('other')),
- )
- kind = models.CharField(verbose_name=_('kind'), max_length=255,
- choices=KIND_CHOICES, default=KIND_WAITING)
- def __str__(self):
- return self.name
- class Meta:
- verbose_name = _("reference point")
- verbose_name_plural = _("reference points")
- class Panorama(ReferencePoint):
- loop = models.BooleanField(default=False, verbose_name=_("360° panorama"),
- help_text=_("Whether the panorama loops around the edges"))
- image = models.ImageField(verbose_name=_("image"), upload_to="pano",
- width_field="image_width",
- height_field="image_height")
- image_width = models.PositiveIntegerField(default=0, verbose_name=_("image width"))
- image_height = models.PositiveIntegerField(default=0, verbose_name=_("image height"))
- # Set of references, i.e. reference points with information on how
- # they relate to this panorama.
- references = models.ManyToManyField(ReferencePoint, through='Reference',
- related_name="referenced_panorama",
- verbose_name=_("references"))
- def tiles_dir(self):
- return os.path.join(settings.MEDIA_ROOT, settings.PANORAMA_TILES_DIR,
- str(self.pk))
- def tiles_url(self):
- return os.path.join(settings.MEDIA_URL, settings.PANORAMA_TILES_DIR,
- str(self.pk))
- def has_tiles(self):
- return path_exists(self.tiles_dir()) and len(os.listdir(self.tiles_dir())) > 0
- has_tiles.boolean = True
- has_tiles.short_description = _("Tiles available?")
- def delete_tiles(self):
- """Delete all tiles and the tiles dir"""
- # If the directory doesn't exist, do nothing
- if not path_exists(self.tiles_dir()):
- return
- # Delete all tiles
- for filename in os.listdir(self.tiles_dir()):
- os.unlink(os.path.join(self.tiles_dir(), filename))
- os.rmdir(self.tiles_dir())
- def generate_tiles(self):
- makedirs(self.tiles_dir(), exist_ok=True)
- generate_tiles.delay(self.image.path, self.tiles_dir())
- def get_absolute_url(self, cap=None, ele=None, zoom=None):
- base_url = reverse('panorama:view_pano', args=[str(self.pk)])
- # Add parameters to point to the given direction, interpreted by
- # the js frontend
- if zoom is None:
- zoom = 0
- if not None in (zoom, cap, ele):
- return base_url + "#zoom={}/cap={}/ele={}".format(zoom, cap, ele)
- else:
- return base_url
- def tiles_data(self):
- """Hack to feed the current js code with tiles data (we should use the
- JSON API instead, and get rid of this function)"""
- data = dict()
- for zoomlevel in range(9):
- width = self.image_width >> zoomlevel
- height = self.image_height >> zoomlevel
- d = dict()
- d["tile_width"] = d["tile_height"] = 256
- # Python3-style division
- d["ntiles_x"] = int(ceil(width / 256))
- d["ntiles_y"] = int(ceil(height / 256))
- d["last_tile_width"] = width % 256
- d["last_tile_height"] = height % 256
- data[zoomlevel] = d
- return data
- def refpoints_data(self):
- """Similar hack, returns all reference points around the panorama."""
- def get_url(refpoint):
- """If the refpoint is also a panorama, returns its canonical URL"""
- if hasattr(refpoint, "panorama"):
- # Point towards the current panorama
- return refpoint.panorama.get_absolute_url(refpoint.bearing(self),
- refpoint.elevation(self))
- else:
- return ""
- refpoints = [refpoint for refpoint in ReferencePoint.objects.all()
- if self.great_circle_distance(refpoint) <= settings.PANORAMA_MAX_DISTANCE and refpoint.pk != self.pk]
- refpoints.sort(key=lambda r: r.name)
- return enumerate([{"id": r.pk,
- "name": r.name,
- "url": get_url(r),
- "cap": self.bearing(r),
- "elevation": self.elevation(r),
- "distance": self.line_distance(r)}
- for r in refpoints])
- def references_data(self):
- """Similar hack, returns all references currently associated to the
- panorama."""
- return [{"id": r.pk,
- "name": r.reference_point.name,
- # Adapt to js-based coordinates (x between 0 and 1, y
- # between -0.5 and 0.5)
- "x": r.x / r.panorama.image_width,
- "y": (r.y / r.panorama.image_height) - 0.5,
- "cap": self.bearing(r.reference_point),
- "elevation": self.elevation(r.reference_point)}
- for r in self.panorama_references.all()]
- def is_visible(self, point):
- """Return True if the Panorama can see the point."""
- if self.great_circle_distance(point) > settings.PANORAMA_MAX_DISTANCE:
- return False
- if self.loop:
- return True
- cap = self.bearing(point) % 360
- cap_min = self.cap_min()
- cap_max = self.cap_max()
- # Not enough references
- if cap_min is None or cap_max is None:
- return False
- if cap_min < cap_max:
- # Nominal case
- return cap_min <= cap <= cap_max
- else:
- return cap_min <= cap or cap <= cap_max
- def cap_min(self):
- return self._cap_minmax(True)
- def cap_max(self):
- return self._cap_minmax(False)
- def _cap_minmax(self, ismin=True):
- """Return the cap on the border of the image.
- :param ismin: True if the min cap should be processed False if it is the
- max.
- @return None if the image is looping or if the image have less than two
- references.
- """
- if self.loop:
- return None
- it = self.panorama_references.order_by(
- 'x' if ismin else '-x').iterator()
- try:
- ref1 = next(it)
- ref2 = next(it)
- except StopIteration:
- return None
- cap1 = self.bearing(ref1.reference_point)
- cap2 = self.bearing(ref2.reference_point)
- target_x = 0 if ismin else self.image_width
- # For circulary issues
- if ismin and cap2 < cap1:
- cap2 += 360
- if (not ismin) and cap1 < cap2:
- cap1 += 360
- target_cap = cap1 + (target_x - ref1.x) * (cap2 - cap1) / \
- (ref2.x - ref1.x)
- return target_cap % 360
- class Meta:
- verbose_name = _("panorama")
- verbose_name_plural = _("panoramas")
- @python_2_unicode_compatible
- class Reference(models.Model):
- """A reference is made of a Panorama, a Reference Point, and the position
- (x, y) of the reference point inside the image. With enough
- references, the panorama is calibrated. That is, we can build a
- mapping between pixels of the image and directions in 3D space, which
- are represented by (azimuth, elevation) couples."""
- # Components of the ManyToMany relation
- reference_point = models.ForeignKey(ReferencePoint, related_name="refpoint_references",
- verbose_name=_("reference point"))
- panorama = models.ForeignKey(Panorama, related_name="panorama_references",
- verbose_name=_("panorama"))
- # Position of the reference point in the panorama image
- x = models.PositiveIntegerField()
- y = models.PositiveIntegerField()
- class Meta:
- # It makes no sense to have multiple references of the same
- # reference point on a given panorama.
- unique_together = (("reference_point", "panorama"),)
- verbose_name = _("reference")
- verbose_name_plural = _("references")
- def clean(self):
- # Check that the reference point and the panorama are different
- # (remember that panoramas can *also* be seen as reference points)
- if self.panorama.pk == self.reference_point.pk:
- raise ValidationError(_("A panorama can't reference itself."))
- # Check than the position is within the bounds of the image.
- w = self.panorama.image_width
- h = self.panorama.image_height
- if self.x >= w or self.y >= h:
- raise ValidationError(_("Position {xy} is outside the bounds "
- "of the image ({width}, {height}).").format(
- xy=(self.x, self.y),
- width=w,
- height=h))
- def __str__(self):
- return _('{refpoint} at {xy} in {pano}').format(
- pano=self.panorama.name,
- xy=(self.x, self.y),
- refpoint=self.reference_point.name,
- )
|