Commit 24e78502 authored by Robin Tissot's avatar Robin Tissot
Browse files

Merge branch 'develop'

parents b87b3512 039d1033
......@@ -151,7 +151,7 @@ class BlockSerializer(serializers.ModelSerializer):
class LineTranscriptionSerializer(serializers.ModelSerializer):
class Meta:
model = LineTranscription
fields = ('pk', 'line', 'transcription', 'content',
fields = ('pk', 'line', 'transcription', 'content', 'graphs',
'versions', 'version_author', 'version_source', 'version_updated_at')
def cleanup(self, data):
......
......@@ -181,6 +181,34 @@ class PartViewSet(DocumentPermissionMixin, ModelViewSet):
serializer = LineOrderSerializer(document_part.lines.all(), many=True)
return Response({'status': 'done', 'lines': serializer.data}, status=200)
@action(detail=True, methods=['post'])
def rotate(self, request, document_pk=None, pk=None):
document_part = DocumentPart.objects.get(pk=pk)
angle = self.request.data.get('angle')
if angle:
document_part.rotate(angle)
return Response({'status': 'done'}, status=200)
else:
return Response({'error': "Post an angle."},
status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['post'])
def crop(self, request, document_pk=None, pk=None):
document_part = DocumentPart.objects.get(pk=pk)
x1 = self.request.data.get('x1')
y1 = self.request.data.get('y1')
x2 = self.request.data.get('x2')
y2 = self.request.data.get('y2')
if (x1 is not None
and y1 is not None
and x2 is not None
and y2 is not None):
document_part.crop(x1, y1, x2, y2)
return Response({'status': 'done'}, status=200)
else:
return Response({'error': "Post corners as x1, y1 (top left) and x2, y2 (bottom right)."},
status=status.HTTP_400_BAD_REQUEST)
class DocumentTranscriptionViewSet(ModelViewSet):
# Note: there is no dedicated Transcription viewset, it's always in the context of a Document
......
......@@ -8,6 +8,7 @@ import subprocess
import uuid
from PIL import Image
from datetime import datetime
from shapely import affinity
from shapely.geometry import Polygon, LineString
from django.db import models, transaction
......@@ -715,6 +716,8 @@ class DocumentPart(OrderedModel):
line=line, transcription=trans)
for pred in it:
lt.content = pred.prediction
lt.graphs = [(char, pred.cuts[i], float(pred.confidences[i]))
for i, char in enumerate(pred.prediction)]
lt.save()
else:
Transcription.objects.get_or_create(
......@@ -781,6 +784,102 @@ class DocumentPart(OrderedModel):
return to_calc
def rotate(self, angle):
"""
Rotates everything in this document part around the center by a given angle (in degrees):
images, lines and regions.
Changes the file system image path to deal with browser cache.
"""
angle_match = re.search(r'_rot(\d+)', self.image.name)
old_angle = angle_match and int(angle_match.group(1)) or 0
new_angle = (old_angle + angle) % 360
def update_name(fpath, old_angle=old_angle, new_angle=new_angle):
# we need to change the name of the file to avoid all kind of cache issues
if old_angle:
if new_angle:
new_name = re.sub(r'(_rot)'+str(old_angle), r'\g<1>'+str(new_angle), fpath)
else:
new_name = re.sub(r'_rot'+str(old_angle), '', fpath)
else:
# if there was no angle before, there is one now
name, ext = os.path.splitext(fpath)
new_name = f'{name}_rot{new_angle}{ext}'
return new_name
# rotate image
with Image.open(self.image.file.name) as im:
# store center point while it's open with old bounds
center = (im.width/2, im.height/2)
rim = im.rotate(360-angle, expand=True, fillcolor=None)
# the image size is shifted so we need to calculate by which offset
# to update points accordingly
new_center = (rim.width/2, rim.height/2)
offset = (center[0]-new_center[0], center[1]-new_center[1])
# Note: self.image.file.name (full path) != self.image.name (relative path)
rim.save(update_name(self.image.file.name))
rim.close()
# save the updated file name in db
self.image = update_name(self.image.name)
# rotate bw image
if self.bw_image:
with Image.open(self.bw_image.file.name) as im:
rim = im.rotate(360-angle, expand=True)
rim.save(update_name(self.bw_image.file.name))
rim.close()
self.bw_image = update_name(self.bw_image.name)
self.save()
get_thumbnailer(self.image).get_thumbnail(settings.THUMBNAIL_ALIASES['']['large'])
# rotate lines
for line in self.lines.all():
if line.baseline:
poly = affinity.rotate(LineString(line.baseline), angle, origin=center)
line.baseline = [(int(x-offset[0]), int(y-offset[1])) for x, y in poly.coords]
if line.mask:
poly = affinity.rotate(Polygon(line.mask), angle, origin=center)
line.mask = [(int(x-offset[0]), int(y-offset[1])) for x, y in poly.exterior.coords]
line.save()
# rotate regions
for region in self.blocks.all():
poly = affinity.rotate(Polygon(region.box), angle, origin=center)
region.box = [(int(x-offset[0]), int(y-offset[1])) for x, y in poly.exterior.coords]
region.save()
def crop(self, x1, y1, x2, y2):
"""
Crops the image ouside the rectangle defined
by top left (x1, y1) and bottom right (x2, y2) points.
Moves the lines and regions accordingly.
"""
with Image.open(self.image.file.name) as im:
cim = im.crop((x1, y1, x2, y2))
cim.save(self.image.file.name)
cim.close()
if self.bw_image:
with Image.open(self.image.file.name) as im:
cim = im.crop((x1, y1, x2, y2))
cim.save(self.image.file.name)
cim.close()
for line in self.lines.all():
if line.baseline:
line.baseline = [(int(x-x1), int(y-y1)) for x, y in line.baseline]
if line.mask:
line.mask = [(int(x-x1), int(y-y1)) for x, y in line.mask]
line.save()
for region in self.blocks.all():
region.box = [(int(x-x1), int(y-y1)) for x, y in region.box]
region.save()
def enforce_line_order(self):
# django-ordered-model doesn't care about unicity and linearity...
lines = self.lines.order_by('order', 'pk')
......@@ -803,6 +902,8 @@ def validate_polygon(value):
def validate_2_points(value):
if value is None:
return
if len(value) < 2:
raise ValidationError(
_('Polygon needs to have at least 2 points, it has %(length)d: %(value)s.'),
......@@ -810,6 +911,8 @@ def validate_2_points(value):
def validate_3_points(value):
if value is None:
return
if len(value) < 3:
raise ValidationError(
_('Polygon needs to have at least 3 points, it has %(length)d: %(value)s.'),
......
......@@ -12,5 +12,10 @@ const SourcePanel = BasePanel.extend({
this.$parent.zoom.register(
this.$el.querySelector('#source-zoom-container'),
{map: true});
},
methods: {
rotate(angle) {
this.part.rotate(angle);
}
}
});
......@@ -26,6 +26,9 @@ const TranscriptionModal = Vue.component('transcriptionmodal', {
$(this.$el).draggable({handle: '.modal-header'});
$(this.$el).resizable();
this.computeStyles();
let input = this.$el.querySelector('#trans-input');
input.focus();
},
watch: {
line(new_, old_) {
......@@ -82,40 +85,86 @@ const TranscriptionModal = Vue.component('transcriptionmodal', {
}.bind(this)).join('');
},
computeStyles() {
// this.zoom.reset();
getLineAngle() {
let p1 = this.line.baseline[0];
let p2 = this.line.baseline[this.line.baseline.length-1];
return Math.atan2(p2[1] - p1[1], p2[0] - p1[0]) * 180 / Math.PI;
},
getRotatedLineBBox() {
// create temporary polygon to calculate the line bounding box
if (this.line.mask) {
var maskPoints = this.line.mask.map(
pt => Math.round(pt[0])+ ','+
Math.round(pt[1])).join(' ');
} else {
// TODO
}
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
let tmppoly = document.createElementNS('http://www.w3.org/2000/svg',
'polygon');
tmppoly.setAttributeNS(null, 'points', maskPoints);
tmppoly.setAttributeNS(null, 'fill', 'red');
// calculate rotation needed to get the line horizontal
let target_angle = READ_DIRECTION == 'rtl' ? 180 : 0;
let angle = target_angle - this.getLineAngle();
// apply it to the polygon and get the resulting bbox
let transformOrigin = this.$parent.part.image.size[0]/2+'px '+this.$parent.part.image.size[1]/2+'px';
tmppoly.style.transformOrigin = transformOrigin;
tmppoly.style.transform = 'rotate('+angle+'deg)';
svg.appendChild(tmppoly);
document.body.appendChild(svg);
let bbox = tmppoly.getBoundingClientRect();
let width = bbox.width;
let height = bbox.height
let top = bbox.top - svg.getBoundingClientRect().top;
let left = bbox.left - svg.getBoundingClientRect().left;
document.body.removeChild(svg); // done its job
return {width: width, height: height, top: top, left: left, angle: angle};
},
computeImgStyles(bbox, ratio, lineHeight, hContext) {
let modalImgContainer = this.$el.querySelector('#modal-img-container');
let img = modalImgContainer.querySelector('img#line-img');
let hContext = 0.6; // vertical context added around the line, in percentage
let poly = this.line.mask || this.line.baseline;
let minx = Math.min.apply(null, poly.map(pt => pt[0]));
let miny = Math.min.apply(null, poly.map(pt => pt[1]));
let maxx = Math.max.apply(null, poly.map(pt => pt[0]));
let maxy = Math.max.apply(null, poly.map(pt => pt[1]));
let width = maxx - minx;
let height = maxy - miny;
// we use the same same vertical context horizontaly
let ratio = modalImgContainer.clientWidth / (width + (2*height*hContext));
var MAX_HEIGHT = Math.round(Math.max(25, (window.innerHeight-200) / 3));
let lineHeight = Math.max(30, Math.round(height*ratio));
if (lineHeight > MAX_HEIGHT) {
// change the ratio so that the image can not get too big
ratio = (MAX_HEIGHT/lineHeight)*ratio;
lineHeight = MAX_HEIGHT;
}
let context = hContext*lineHeight;
let visuHeight = lineHeight + 2*context;
modalImgContainer.style.height = visuHeight+'px';
img.style.width = this.$parent.part.image.size[0]*ratio +'px';
let top = Math.round(miny*ratio)-context;
let left = Math.round(minx*ratio)-context;
let right = Math.round(maxx*ratio)-context;
img.style.top = -top+'px';
img.style.left = -left+'px';
let top = -(bbox.top*ratio - context);
let left = -(bbox.left*ratio - context);
// modalImgContainer.style.transform = 'scale('+ratio+')';
let imgWidth = this.$parent.part.image.size[0]*ratio +'px';
let transformOrigin = this.$parent.part.image.size[0]*ratio/2+'px '+this.$parent.part.image.size[1]*ratio/2+'px';
let transform = 'translate('+left+'px, '+top+'px) rotate('+bbox.angle+'deg)';
img.style.width = imgWidth;
img.style.transformOrigin = transformOrigin;
img.style.transform = transform;
// Overlay
let overlay = modalImgContainer.querySelector('.overlay');
if (this.line.mask) {
let maskPoints = this.line.mask.map(
pt => Math.round(pt[0]*ratio)+ ','+
Math.round(pt[1]*ratio)).join(' ');
let polygon = overlay.querySelector('polygon');
polygon.setAttribute('points', maskPoints);
overlay.style.width = imgWidth;
overlay.style.height = this.$parent.part.image.size[1]*ratio+'px';
overlay.style.transformOrigin = transformOrigin;
overlay.style.transform = transform;
overlay.style.display = 'block';
} else {
// TODO: fake mask?!
overlay.style.display = 'none';
}
},
computeInputStyles(bbox, ratio, lineHeight, hContext) {
// Content input
let container = this.$el.querySelector('#trans-modal #trans-input-container');
let input = container.querySelector('#trans-input');
......@@ -128,18 +177,21 @@ const TranscriptionModal = Vue.component('transcriptionmodal', {
ruler.style.whiteSpace="nowrap"
container.appendChild(ruler);
let fontSize = Math.round(lineHeight*0.7); // Note could depend on the script
let context = hContext*lineHeight;
let fontSize = Math.max(15, Math.round(lineHeight*0.7)); // Note could depend on the script
ruler.style.fontSize = fontSize+'px';
input.style.fontSize = fontSize+'px';
input.style.height = 'auto';
input.style.height = Math.round(fontSize*1.1)+'px';
if (READ_DIRECTION == 'rtl') {
container.style.marginRight = context+'px';
} else {
// left to right
// TODO: deal with other directions
container.style.marginLeft = context+'px';
}
if (content) {
let lineWidth = width*ratio;
let lineWidth = bbox.width*ratio;
var scaleX = Math.min(5, lineWidth / ruler.clientWidth);
scaleX = Math.max(0.2, scaleX);
input.style.transform = 'scaleX('+ scaleX +')';
......@@ -149,20 +201,28 @@ const TranscriptionModal = Vue.component('transcriptionmodal', {
input.style.width = '100%'; //'calc(100% - '+context+'px)';
}
container.removeChild(ruler); // done its job
},
input.focus();
computeStyles() {
/*
Centers the image on the line (zoom + rotation)
Modifies input font size and height to match the image
*/
let modalImgContainer = this.$el.querySelector('#modal-img-container');
// Overlay
let overlay = modalImgContainer.querySelector('.overlay');
if (this.line.mask) {
let maskPoints = this.line.mask.map(
pt => Math.round(pt[0]*ratio-left)+ ','+
Math.round(pt[1]*ratio-top)).join(' ');
overlay.querySelector('polygon').setAttribute('points', maskPoints);
overlay.style.display = 'block';
} else {
overlay.style.display = 'none';
bbox = this.getRotatedLineBBox();
let hContext = 0.3; // vertical context added around the line, in percentage
let ratio = modalImgContainer.clientWidth / (bbox.width + (2*bbox.height*hContext));
let MAX_HEIGHT = Math.round(Math.max(25, (window.innerHeight-230) / 3));
let lineHeight = Math.max(30, Math.round(bbox.height*ratio));
if (lineHeight > MAX_HEIGHT) {
// change the ratio so that the image can not get too big
ratio = (MAX_HEIGHT/lineHeight)*ratio;
lineHeight = MAX_HEIGHT;
}
}
this.computeImgStyles(bbox, ratio, lineHeight, hContext);
this.computeInputStyles(bbox, ratio, lineHeight, hContext);
},
},
});
......@@ -336,6 +336,16 @@ const partStore = {
}
this.debouncedRecalculateOrdering();
},
rotate(angle, callback) {
let uri = this.getApiPart() + 'rotate/';
this.push(uri, {angle: angle}, method="post")
.then(function(data) {
this.reload(callback);
}.bind(this))
.catch(function(error) {
console.log('couldnt rotate!', error);
});
},
createRegion(region, callback) {
let uri = this.getApiPart() + 'blocks/';
......@@ -503,5 +513,10 @@ const partStore = {
this.reset();
this.fetchPart(this.next, cb);
}
},
reload(cb) {
let pk = this.pk;
this.reset();
this.fetchPart(pk, cb);
}
};
......@@ -4,6 +4,7 @@ import logging
import numpy as np
import os.path
import shutil
from itertools import groupby
from django.apps import apps
from django.conf import settings
......@@ -115,9 +116,12 @@ def binarize(instance_pk, user_pk=None, binarizer=None, threshold=None, **kwargs
def make_segmentation_training_data(part):
return {
'image': part.image.path,
'baselines': [{'script': 'default', 'baseline': bl}
'baselines': [{'script': bl.typology.name or 'default',
'baseline': bl}
for bl in part.lines.values_list('baseline', flat=True) if bl],
'regions': {'default': [r.box for r in part.blocks.all().only('box')]}
'regions': {typo: regs for typo, regs in groupby(
(r.box for r in part.blocks.all().order_by('typology')),
key=lambda reg: reg.typology.name)}
}
......
......@@ -6,7 +6,7 @@ from django.contrib import messages
from django.utils.translation import ngettext
from users.models import User, ResearchField, Invitation, ContactUs
from users.models import User, ResearchField, Invitation, ContactUs, GroupOwner
class MyUserChangeForm(UserChangeForm):
......@@ -39,7 +39,8 @@ class MyUserAdmin(UserAdmin):
class InvitationAdmin(admin.ModelAdmin):
date_hierarchy = 'created_at'
list_filter = ('workflow_state', 'group')
list_display = ('recipient_email', 'recipient_last_name', 'recipient_first_name', 'sender', 'workflow_state')
list_display = ('recipient_email', 'recipient_last_name', 'recipient_first_name',
'sender', 'workflow_state')
readonly_fields = ('sender', 'recipient', 'token', 'created_at', 'sent_at', 'workflow_state')
actions = ['resend']
......@@ -63,4 +64,4 @@ admin.site.register(User, MyUserAdmin)
admin.site.register(ResearchField)
admin.site.register(Invitation, InvitationAdmin)
admin.site.register(ContactUs)
admin.site.register(GroupOwner)
from django import forms
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.db.models import Q
from django.contrib.auth.forms import UserCreationForm
from django.utils.translation import gettext as _
from captcha.fields import CaptchaField
from django.contrib.auth.models import Group
from captcha.fields import CaptchaField
from bootstrap.forms import BootstrapFormMixin
from users.models import Invitation, User, ContactUs
from users.models import Invitation, User, ContactUs, GroupOwner
class InvitationForm(BootstrapFormMixin, forms.ModelForm):
......@@ -24,11 +26,43 @@ class InvitationForm(BootstrapFormMixin, forms.ModelForm):
def save(self, commit=True):
invitation = super().save(commit=False)
invitation.sender = self.sender
invitation.save()
invitation.send(self.request)
if commit:
invitation.save()
invitation.send(self.request)
return invitation
class GroupInvitationForm(InvitationForm):
recipient_id = forms.CharField(label=_("Email or username."))
class Meta:
model = Invitation
fields = ['recipient_id', 'group']
def clean_recipient_id(self):
# we don't throw an error on purpose to avoid fishing
try:
return User.objects.get(Q(email=self.data.get('recipient_id')) |
Q(username=self.data.get('recipient_id')))
except User.DoesNotExist:
return None
def clean(self):
data = super().clean()
return data
def save(self, commit=True):
recipient = self.cleaned_data['recipient_id']
print('recipient', recipient)
if recipient:
invitation = super().save(commit=False)
invitation.recipient = recipient
if commit:
invitation.save()
invitation.send(self.request)
return invitation
class InvitationAcceptForm(BootstrapFormMixin, UserCreationForm):
"""
This is a registration form since a user is created.
......@@ -55,10 +89,61 @@ class ProfileForm(BootstrapFormMixin, forms.ModelForm):
fields = ('email', 'first_name', 'last_name')
class ContactUsForm(BootstrapFormMixin, forms.ModelForm):
class GroupForm(BootstrapFormMixin, forms.ModelForm):
class Meta:
model = Group
fields = ('name',)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('request').user
super().__init__(*args, **kwargs)
def save(self, commit=True):
group = super().save(commit=True)
group.user_set.add(self.user)
GroupOwner.objects.create(
group=group,
owner=self.user)
return group
message = forms.CharField(widget=forms.Textarea(attrs={
'placeholder': 'Message : Please precise your institution or research center if applicable'}))
class RemoveUserFromGroup(forms.ModelForm):
user = forms.ModelChoiceField(queryset=User.objects.all())
class Meta:
model = Group
fields = ('user',)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['user'].queryset = self.instance.user_set.exclude(
pk=self.instance.groupowner.owner.pk)
def save(self, commit=True):
self.instance.user_set.remove(self.cleaned_data['user'])
class TransferGroupOwnershipForm(forms.ModelForm):
user = forms.ModelChoiceField(queryset=User.objects.all())
class Meta:
model = Group
fields = ('user',)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['user'].queryset = self.instance.user_set.exclude(
pk=self.instance.groupowner.owner.pk)
def save(self, commit=True):
self.instance.groupowner.owner = self.cleaned_data['user']
self.instance.groupowner.save()