Merge working version
BIN
1.jpg
Before Width: | Height: | Size: 3.7 MiB |
BIN
2.jpg
Before Width: | Height: | Size: 3.7 MiB |
BIN
3.jpg
Before Width: | Height: | Size: 3.6 MiB |
2
config.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
DEBUG = False
|
||||
SECRET_KEY = b'needs_to_be_set!'
|
1
migrations/README
Normal file
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration.
|
45
migrations/alembic.ini
Normal file
|
@ -0,0 +1,45 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
87
migrations/env.py
Normal file
|
@ -0,0 +1,87 @@
|
|||
from __future__ import with_statement
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from logging.config import fileConfig
|
||||
import logging
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
from flask import current_app
|
||||
config.set_main_option('sqlalchemy.url',
|
||||
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
|
||||
target_metadata = current_app.extensions['migrate'].db.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
engine = engine_from_config(config.get_section(config.config_ini_section),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool)
|
||||
|
||||
connection = engine.connect()
|
||||
context.configure(connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
process_revision_directives=process_revision_directives,
|
||||
**current_app.extensions['migrate'].configure_args)
|
||||
|
||||
try:
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
migrations/script.py.mako
Normal file
|
@ -0,0 +1,24 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
60
migrations/versions/5b75d2439618_.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 5b75d2439618
|
||||
Revises:
|
||||
Create Date: 2018-10-28 15:26:30.086363
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '5b75d2439618'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('camera_settings',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=64), nullable=True),
|
||||
sa.Column('iso', sa.Integer(), nullable=True),
|
||||
sa.Column('fps', sa.Integer(), nullable=True),
|
||||
sa.Column('width', sa.Integer(), nullable=True),
|
||||
sa.Column('height', sa.Integer(), nullable=True),
|
||||
sa.Column('fix_shutter', sa.Boolean(), nullable=True),
|
||||
sa.Column('fix_wb', sa.Boolean(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_camera_settings_name'), 'camera_settings', ['name'], unique=True)
|
||||
op.create_table('dataset',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('title', sa.String(length=64), nullable=True),
|
||||
sa.Column('datetime_created', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_dataset_title'), 'dataset', ['title'], unique=False)
|
||||
op.create_table('image',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('filename', sa.String(length=128), nullable=True),
|
||||
sa.Column('datetime_created', sa.DateTime(), nullable=True),
|
||||
sa.Column('dataset_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['dataset_id'], ['dataset.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_image_filename'), 'image', ['filename'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_image_filename'), table_name='image')
|
||||
op.drop_table('image')
|
||||
op.drop_index(op.f('ix_dataset_title'), table_name='dataset')
|
||||
op.drop_table('dataset')
|
||||
op.drop_index(op.f('ix_camera_settings_name'), table_name='camera_settings')
|
||||
op.drop_table('camera_settings')
|
||||
# ### end Alembic commands ###
|
|
@ -1 +1,6 @@
|
|||
from phytopi import app
|
||||
from phytopi import app, db
|
||||
from phytopi.models import Dataset, Image
|
||||
|
||||
@app.shell_context_processor
|
||||
def make_shell_context():
|
||||
return {'db': db, 'Dataset': Dataset, 'Image': Image}
|
||||
|
|
|
@ -1,22 +1,16 @@
|
|||
import os
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from phytopi.config import Config
|
||||
# from phytopi.camera.camera_pi import Camera
|
||||
|
||||
def create_app(config_filename=None):
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
if config_filename is None:
|
||||
app.config.from_pyfile('config.py', silent=True)
|
||||
else:
|
||||
app.config.from_pyfile(config_filename, silent=True)
|
||||
app.config.from_object(Config)
|
||||
|
||||
try:
|
||||
os.makedirs(app.instance_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
from phytopi.views import frontend
|
||||
|
||||
app.register_blueprint(frontend.bp)
|
||||
|
||||
return app
|
||||
db = SQLAlchemy(app)
|
||||
migrate = Migrate(app, db)
|
||||
# camera = Camera(app=app)
|
||||
|
||||
from phytopi import routes, models
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import time
|
||||
import threading
|
||||
from PIL import Image
|
||||
from PIL import ImageDraw
|
||||
from PIL import ImageFont
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from phytopi import db, models, app
|
||||
|
||||
try:
|
||||
from greenlet import getcurrent as get_ident
|
||||
except ImportError:
|
||||
|
@ -8,7 +16,6 @@ except ImportError:
|
|||
except ImportError:
|
||||
from _thread import get_ident
|
||||
|
||||
|
||||
class CameraEvent(object):
|
||||
"""An Event-like class that signals all active clients when a new frame is
|
||||
available.
|
||||
|
@ -54,14 +61,25 @@ class CameraEvent(object):
|
|||
class BaseCamera(object):
|
||||
thread = None # background thread that reads frames from camera
|
||||
frame = None # current frame is stored here by background thread
|
||||
last_access = 0 # time of last client access to the camera
|
||||
live_mode = True # live view?
|
||||
# last_access = 0 # time of last client access to the camera
|
||||
last_saved = None
|
||||
timelapse_interval = None
|
||||
event = CameraEvent()
|
||||
image_counter = 0
|
||||
camera = None
|
||||
fixed_shutter = False
|
||||
fixed_white_balance = False
|
||||
current_dataset = None
|
||||
app = None
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, timelapse_interval=None, app=None):
|
||||
"""Start the background camera thread if it isn't running yet."""
|
||||
if app:
|
||||
BaseCamera.app = app
|
||||
if timelapse_interval:
|
||||
BaseCamera.timelapse_interval = timelapse_interval
|
||||
if BaseCamera.thread is None:
|
||||
BaseCamera.last_access = time.time()
|
||||
# BaseCamera.last_access = time.time()
|
||||
|
||||
# start background frame thread
|
||||
BaseCamera.thread = threading.Thread(target=self._thread)
|
||||
|
@ -71,14 +89,39 @@ class BaseCamera(object):
|
|||
while self.get_frame() is None:
|
||||
time.sleep(0)
|
||||
|
||||
def get_frame(self):
|
||||
def set_timelapse_interval(self, interval=None):
|
||||
BaseCamera.timelapse_interval = interval
|
||||
|
||||
def reset_image_counter(self):
|
||||
BaseCamera.image_counter = 0
|
||||
|
||||
def get_frame(self, thumbnail=False):
|
||||
"""Return the current camera frame."""
|
||||
BaseCamera.last_access = time.time()
|
||||
# BaseCamera.last_access = time.time()
|
||||
|
||||
# wait for a signal from the camera thread
|
||||
BaseCamera.event.wait()
|
||||
BaseCamera.event.clear()
|
||||
|
||||
# if a thumbnail is requested, resize frame and deliver
|
||||
if thumbnail:
|
||||
with Image.open(BytesIO(BaseCamera.frame)) as img:
|
||||
img.thumbnail((500, 500), Image.ANTIALIAS)
|
||||
font = ImageFont.truetype(font='phytopi/fonts/Hack-Bold.ttf')
|
||||
txt = str(time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
txt_width, txt_height = font.getsize(txt)
|
||||
draw = ImageDraw.Draw(img)
|
||||
txt_x, txt_y = (2, 2)
|
||||
draw.rectangle((0, 0, txt_x + txt_width + 2, txt_y + txt_height + 2), fill='black')
|
||||
draw.text((2, 2), txt, (255, 255, 255), font=font)
|
||||
if BaseCamera.timelapse_interval:
|
||||
img_width, img_height = img.size
|
||||
draw.ellipse([(img_width - 15, 5),(img_width-5, 15)], fill='red')
|
||||
with BytesIO() as live_out:
|
||||
img.save(live_out, format='JPEG')
|
||||
thumbnail = live_out.getvalue()
|
||||
return thumbnail
|
||||
|
||||
return BaseCamera.frame
|
||||
|
||||
@staticmethod
|
||||
|
@ -86,20 +129,28 @@ class BaseCamera(object):
|
|||
""""Generator that returns frames from the camera."""
|
||||
raise RuntimeError('Must be implemented by subclasses.')
|
||||
|
||||
@classmethod
|
||||
def _thread(cls):
|
||||
# @classmethod
|
||||
def _thread(self):
|
||||
"""Camera background thread."""
|
||||
print('Starting camera thread.')
|
||||
frames_iterator = cls.frames()
|
||||
frames_iterator = self.frames()
|
||||
for frame in frames_iterator:
|
||||
BaseCamera.frame = frame
|
||||
|
||||
BaseCamera.event.set() # send signal to clients
|
||||
time.sleep(0)
|
||||
if self.timelapse_interval is not None:
|
||||
if self.last_saved is None or time.time() - self.last_saved >= self.timelapse_interval:
|
||||
self.last_saved = time.time()
|
||||
timestr = time.strftime("%Y%m%d_%H%M%S", time.localtime(self.last_saved))
|
||||
filename = 'tl-{}.jpg'.format(timestr)
|
||||
with open('{}/{}'.format(BaseCamera.app.config['DATA_PATH'], filename), 'wb') as f:
|
||||
f.write(BaseCamera.frame)
|
||||
print("Saved {}".format(filename))
|
||||
print(self.current_dataset)
|
||||
db.session.add(models.Image(filename=filename, dataset_id=self.current_dataset))
|
||||
db.session.commit()
|
||||
self.image_counter += 1
|
||||
|
||||
BaseCamera.thread = None
|
||||
|
||||
# if there hasn't been any clients asking for frames in
|
||||
# the last 10 seconds then stop the thread
|
||||
if time.time() - BaseCamera.last_access > 10:
|
||||
frames_iterator.close()
|
||||
print('Stopping camera thread due to inactivity.')
|
||||
break
|
||||
BaseCamera.thread = None
|
|
@ -1,14 +0,0 @@
|
|||
import time
|
||||
from phytopi.camera.base_camera import BaseCamera
|
||||
|
||||
|
||||
class Camera(BaseCamera):
|
||||
"""An emulated camera implementation that streams a repeated sequence of
|
||||
files 1.jpg, 2.jpg and 3.jpg at a rate of one frame per second."""
|
||||
imgs = [open(f + '.jpg', 'rb').read() for f in ['1', '2', '3']]
|
||||
|
||||
@staticmethod
|
||||
def frames():
|
||||
while True:
|
||||
time.sleep(1)
|
||||
yield Camera.imgs[int(time.time()) % 3]
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import io
|
||||
import time
|
||||
import picamera
|
||||
|
@ -6,27 +5,39 @@ from phytopi.camera.base_camera import BaseCamera
|
|||
|
||||
|
||||
class Camera(BaseCamera):
|
||||
@staticmethod
|
||||
def set_live_mode(live_mode=True):
|
||||
Camera.live_mode = live_mode
|
||||
|
||||
@staticmethod
|
||||
def frames():
|
||||
with picamera.PiCamera() as camera:
|
||||
stream = io.BytesIO()
|
||||
with picamera.PiCamera(sensor_mode=3, framerate=15, resolution=(3280, 2464)) as camera:
|
||||
BaseCamera.camera = camera
|
||||
# camera.iso = 100
|
||||
# let camera warm up
|
||||
time.sleep(2)
|
||||
time.sleep(60)
|
||||
# if BaseCamera.fixed_shutter:
|
||||
# camera.shutter_speed = camera.exposure_speed
|
||||
# camera.exposure_mode = 'off'
|
||||
# if BaseCamera.fixed_white_balance:
|
||||
# g = camera.awb_gains
|
||||
# camera.awb_mode = 'off'
|
||||
# camera.awb_gains = g
|
||||
|
||||
if Camera.live_mode:
|
||||
stream = io.BytesIO()
|
||||
for _ in camera.capture_continuous(stream, 'jpeg', use_video_port=True):
|
||||
# return current frame
|
||||
stream.seek(0)
|
||||
yield stream.read()
|
||||
# camera.framerate = 15
|
||||
|
||||
# stream = io.BytesIO()
|
||||
# while True:
|
||||
# camera.capture(stream, 'jpeg', use_video_port=False, quality=100)
|
||||
# stream.seek(0)
|
||||
# yield stream.read()
|
||||
# stream.seek(0)
|
||||
# stream.truncate()
|
||||
|
||||
for _ in camera.capture_continuous(stream, 'jpeg', use_video_port=False, quality=100):
|
||||
# return current frame
|
||||
stream.seek(0)
|
||||
yield stream.read()
|
||||
|
||||
# reset stream for next frame
|
||||
stream.seek(0)
|
||||
stream.truncate()
|
||||
else:
|
||||
for filename in camera.capture_continuous('tl-{timestamp:%Y%m%d_%H%M}.jpg', 'jpg',
|
||||
use_video_port=False):
|
||||
yield open(filename, 'rb').read()
|
||||
# reset stream for next frame
|
||||
stream.seek(0)
|
||||
stream.truncate()
|
||||
time.sleep(2)
|
||||
|
|
10
phytopi/config.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
import os
|
||||
import binascii
|
||||
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
class Config(object):
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or binascii.hexlify(os.urandom(24))
|
||||
DATA_PATH = os.environ.get('DATA_PATH') or os.path.join(basedir, 'static/data')
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') or 'sqlite:///' + os.path.join(basedir, 'phytopi.db')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = os.environ.get('SQLALCHEMY_TRACK_MODIFICATIONS') or False
|
BIN
phytopi/fonts/Hack-Bold.ttf
Normal file
BIN
phytopi/fonts/Hack-Regular.ttf
Normal file
11
phytopi/forms.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, BooleanField, SubmitField
|
||||
from wtforms.validators import NumberRange, DataRequired
|
||||
|
||||
|
||||
class CameraSettingsForm(FlaskForm):
|
||||
camera_iso = IntegerField('ISO', validators=[NumberRange(min=0), DataRequired()])
|
||||
camera_fps = IntegerField('Framerate', validators=[NumberRange(min=1, max=60), DataRequired()])
|
||||
camera_fix_shutter = BooleanField('Fixed exposure time')
|
||||
camera_fix_wb = BooleanField('Fix white balance')
|
||||
submit = SubmitField('Save & Apply')
|
45
phytopi/models.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
from datetime import datetime
|
||||
from phytopi import db
|
||||
|
||||
class Dataset(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
title = db.Column(db.String(64), index=True)
|
||||
datetime_created = db.Column(db.DateTime, default=datetime.utcnow, )
|
||||
images = db.relationship('Image', backref='dataset', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return '<Dataset {}>'.format(self.datetime_created)
|
||||
|
||||
def __index__(self):
|
||||
return int(self.id)
|
||||
|
||||
class Image(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
filename = db.Column(db.String(128), index=True, unique=True)
|
||||
datetime_created = datetime_created = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
dataset_id = db.Column(db.Integer, db.ForeignKey('dataset.id'))
|
||||
|
||||
def __repr__(self):
|
||||
return '<Image {}>'.format(self.filename)
|
||||
|
||||
class CameraSettings(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
name = db.Column(db.String(64), index=True, unique=True)
|
||||
iso = db.Column(db.Integer, default=100)
|
||||
fps = db.Column(db.Integer, default=5)
|
||||
width = db.Column(db.Integer, default=3280)
|
||||
height = db.Column(db.Integer, default=2464)
|
||||
fix_shutter = db.Column(db.Boolean, default=False)
|
||||
fix_wb = db.Column(db.Boolean, default=False)
|
||||
|
||||
def __init__(self, name, iso=100, fps=5, width=3280, height=2464, fix_shutter=False, fix_wb=False):
|
||||
self.name = name
|
||||
self.iso = iso
|
||||
self.fps = fps
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.fix_shutter = fix_shutter
|
||||
self.fix_wb = fix_wb
|
||||
|
||||
def __repr__(self):
|
||||
return '<CameraSettings {}>'.format(self.name)
|
BIN
phytopi/phytopi.db
Normal file
115
phytopi/routes.py
Normal file
|
@ -0,0 +1,115 @@
|
|||
from flask import render_template, Response, flash, jsonify, request, stream_with_context, send_file
|
||||
|
||||
from phytopi.camera.camera_pi import Camera
|
||||
from phytopi.forms import CameraSettingsForm
|
||||
|
||||
from phytopi import app
|
||||
from phytopi import db
|
||||
from phytopi.models import Dataset
|
||||
|
||||
from io import BytesIO
|
||||
import zipstream
|
||||
|
||||
camera = Camera(app=app)
|
||||
# camera = app.camera
|
||||
save_frames = False
|
||||
|
||||
@app.route('/')
|
||||
@app.route('/index')
|
||||
def index():
|
||||
content = {'timelapse': save_frames}
|
||||
return render_template('index.html', content=content)
|
||||
|
||||
@app.route('/toggle_timelapse', methods=['GET', 'POST'])
|
||||
def start_stop_timelapse():
|
||||
global save_frames
|
||||
global current_dataset
|
||||
global camera
|
||||
if save_frames:
|
||||
save_frames = False
|
||||
timelapse_interval = None
|
||||
btn_text = "Start"
|
||||
btn_class = 'btn-primary'
|
||||
camera.current_dataset = None
|
||||
camera.last_saved = None
|
||||
print(" > switched off timelapse mode")
|
||||
else:
|
||||
save_frames = True
|
||||
timelapse_interval = 1200
|
||||
btn_text = "Stop"
|
||||
btn_class = 'btn-danger'
|
||||
dataset = Dataset()
|
||||
db.session.add(dataset)
|
||||
db.session.commit()
|
||||
camera.current_dataset = dataset.id
|
||||
print(" > switched on timelapse mode")
|
||||
|
||||
camera.set_timelapse_interval(timelapse_interval)
|
||||
return jsonify(btn_text=btn_text, btn_class=btn_class)
|
||||
|
||||
|
||||
def gen(camera):
|
||||
# def gen():
|
||||
"""Video streaming generator function."""
|
||||
while True:
|
||||
frame = camera.get_frame(thumbnail=True)
|
||||
yield (b'--frame\r\n'
|
||||
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')
|
||||
|
||||
|
||||
@app.route('/data')
|
||||
def data():
|
||||
return Response(app.config['DATA_PATH'])
|
||||
|
||||
@app.route('/video_feed')
|
||||
def video_feed():
|
||||
global camera
|
||||
return Response(gen(camera),
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame')
|
||||
|
||||
@app.route('/settings', methods=['GET', 'POST'])
|
||||
def settings():
|
||||
form = CameraSettingsForm()
|
||||
return render_template('settings.html', form=form)
|
||||
#global camera
|
||||
#camera_settings = {'fps': camera.camera.framerate,
|
||||
# 'iso': camera.camera.iso}
|
||||
#return render_template('settings.html', settings=camera_settings)
|
||||
|
||||
@app.route('/datasets')
|
||||
@app.route('/datasets/<dataset_id>')
|
||||
def datasets(dataset_id=None):
|
||||
if dataset_id:
|
||||
dataset = Dataset.query.get(int(dataset_id))
|
||||
data = dataset.images.all()
|
||||
else:
|
||||
data = Dataset.query.order_by(Dataset.datetime_created.desc()).all()
|
||||
|
||||
content = {'data': data,
|
||||
'id': dataset_id}
|
||||
return render_template('datasets.html', content=content)
|
||||
|
||||
@app.route('/datasets/<dataset_id>/delete')
|
||||
def dataset_delete(dataset_id):
|
||||
Dataset.query.filter_by(id=dataset_id).delete()
|
||||
db.session.commit()
|
||||
return datasets()
|
||||
|
||||
@app.route('/datasets/<dataset_id>/download')
|
||||
def dataset_download(dataset_id):
|
||||
images = Dataset.query.get(int(dataset_id)).images.all()
|
||||
|
||||
def generateZip(images):
|
||||
zf = zipstream.ZipFile(mode='w', compression=zipstream.ZIP_DEFLATED)
|
||||
for image in images:
|
||||
filename = '{}/{}'.format(app.config['DATA_PATH'], image.filename)
|
||||
zf.write(filename, arcname=image.filename)
|
||||
for chunk in zf:
|
||||
yield chunk
|
||||
zf.close()
|
||||
|
||||
|
||||
#return send_file(generateZip(images), attachment_filename='dataset_{}.zip'.format(dataset_id), as_attachment=True)
|
||||
response = Response(stream_with_context(generateZip(images)), mimetype='application/zip')
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=dataset_{}.zip'.format(dataset_id)
|
||||
return response
|
BIN
phytopi/static/img/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 3.7 KiB |
BIN
phytopi/static/img/favicon.ico
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
phytopi/static/img/favicon.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
54
phytopi/static/img/icon.svg
Normal file
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 511.998 511.998" style="enable-background:new 0 0 511.998 511.998;" xml:space="preserve">
|
||||
<path style="fill:#23A24D;" d="M256,80.332c0-161.271,213.105,0,213.105,0S256,241.603,256,80.332z"/>
|
||||
<path style="fill:#43B05C;" d="M256,80.332c0,161.271-213.105,0-213.105,0S256-80.939,256,80.332z"/>
|
||||
<polygon style="fill:#704324;" points="388.469,307.515 388.469,365.118 353.909,365.118 158.09,365.118 123.53,365.118
|
||||
123.53,307.515 "/>
|
||||
<rect x="158.086" y="365.122" style="fill:#A26234;" width="195.819" height="138.227"/>
|
||||
<path d="M474.328,73.431c-1.309-0.989-32.469-24.465-71.395-44.738c-53.992-28.116-94.95-35.614-121.747-22.29
|
||||
c-11.128,5.534-19.533,14.44-25.186,26.622c-5.652-12.183-14.058-21.088-25.186-26.622c-26.796-13.327-67.758-5.825-121.746,22.291
|
||||
C70.141,48.965,38.98,72.442,37.672,73.431l-9.119,6.9l9.119,6.9c1.307,0.989,32.469,24.465,71.395,44.738
|
||||
c36.689,19.108,67.36,28.692,91.66,28.692c11.458,0,21.5-2.131,30.086-6.402c6.419-3.192,11.929-7.51,16.532-12.926v157.524H114.876
|
||||
v74.912h34.56v138.227h213.128V373.77h34.56v-74.912H264.654V141.336c4.602,5.415,10.113,9.733,16.531,12.925
|
||||
c8.587,4.271,18.626,6.402,30.087,6.402c24.298,0,54.974-9.588,91.66-28.693c38.925-20.272,70.086-43.748,71.394-44.738l9.119-6.9
|
||||
L474.328,73.431z M345.255,494.691H166.744v-74.843h95.012V402.54h-95.012v-28.767h178.511V494.691z M379.815,356.463h-247.63
|
||||
v-40.295h247.63V356.463z M223.111,138.761c-21.224,10.562-57.828,2.939-105.851-22.039c-25.188-13.102-47.464-27.931-59.556-36.391
|
||||
c12.048-8.433,34.217-23.193,59.357-36.286C165.181,18.987,201.85,11.328,223.105,21.9c14.521,7.221,22.577,23.949,24.006,49.778
|
||||
h-45.82V40.014h-17.309v31.663h-54.696v17.309h31.663v25.905h17.309V88.987h68.851C245.682,114.81,237.628,131.538,223.111,138.761z
|
||||
M394.74,116.722c-48.022,24.978-84.625,32.6-105.852,22.039c-14.517-7.222-22.571-23.95-24-49.774h45.807v31.663h17.309V88.987
|
||||
h54.707V71.678h-31.662V45.773H333.74v25.905h-68.852c1.429-25.825,9.483-42.552,24-49.775c6.206-3.087,13.725-4.62,22.46-4.62
|
||||
c21.143,0,49.409,8.984,83.393,26.66c25.188,13.1,47.462,27.929,59.554,36.39C442.202,88.793,419.928,103.621,394.74,116.722z"/>
|
||||
<rect x="279.04" y="402.543" width="23.032" height="17.309"/>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
BIN
phytopi/static/img/mstile-144x144.png
Normal file
After Width: | Height: | Size: 3 KiB |
|
@ -8,8 +8,17 @@
|
|||
<meta name="author" content="{{ author }}">
|
||||
{% endif %}
|
||||
|
||||
<link rel="shortcut icon" href="{{url_for('static', filename='img/favicon.ico') }}">
|
||||
<link rel="icon" type="image/png" href="{{url_for('static', filename='img/favicon.png') }}" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="{{url_for('static', filename='img/favicon.png') }}" sizes="96x96">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{url_for('static', filename='img/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{url_for('static', filename='img/icon.svg') }}" sizes="any">
|
||||
|
||||
<link rel="stylesheet" href="{{url_for('static', filename='css/bootstrap.css') }}">
|
||||
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="{{url_for('static', filename='img/mstile-144x144.png') }}">
|
||||
|
||||
{% if title %}
|
||||
<title>{{ title }}</title>
|
||||
{% else %}
|
||||
|
@ -21,23 +30,20 @@
|
|||
|
||||
<script>
|
||||
$(document).ready(function(){
|
||||
$("#start_button").click(function(e){
|
||||
e.preventDefault();
|
||||
$("#start_stop_button").click(function(e){
|
||||
e.preventDefault();
|
||||
$.ajax({type: "POST",
|
||||
url: "/start",
|
||||
url: "/toggle_timelapse",
|
||||
data: {},
|
||||
success:function(result){
|
||||
$("#start_button").html(result);
|
||||
$("#start_stop_button").html(result.btn_text);
|
||||
$("#start_stop_button").removeClass("btn-primary btn-danger")
|
||||
$("#start_stop_button").addClass(result.btn_class)
|
||||
}});
|
||||
});
|
||||
$("#stop_button").click(function(e){
|
||||
$("#settings_button").click(function(e){
|
||||
e.preventDefault();
|
||||
$.ajax({type: "POST",
|
||||
url: "/stop",
|
||||
data: {},
|
||||
success:function(result){
|
||||
$("#stop_button").html(result);
|
||||
}});
|
||||
location.href='{{url_for('settings')}}';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
@ -45,8 +51,8 @@
|
|||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<a class="navbar-brand" href="#">
|
||||
<img src="https://dummyimage.com/30x30" width="30" height="30" class="d-inline-block align-top" alt="">
|
||||
<a class="navbar-brand" href="/">
|
||||
<img src="{{url_for('static', filename='img/icon.svg')}}" width="30" height="30" class="d-inline-block align-top" alt="phytopi">
|
||||
phytopi
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
|
@ -54,14 +60,13 @@
|
|||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<div class="navbar-nav">
|
||||
<a class="nav-item nav-link active" href="#">Home</a>
|
||||
<a class="nav-item nav-link active" href="#">Datasets</a>
|
||||
<a class="nav-item nav-link active" href="#">Settings</a>
|
||||
<a class="nav-item nav-link active" href="{{url_for('index')}}">Home</a>
|
||||
<a class="nav-item nav-link active" href="{{url_for('datasets')}}">Datasets</a>
|
||||
<a class="nav-item nav-link active" href="{{url_for('settings')}}">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container-fluid">
|
||||
</div>
|
||||
<div class="container">
|
||||
{% for message in get_flashed_messages() %}
|
||||
<div class="row">
|
||||
<div class="col">{{ message }}</div>
|
||||
|
|
50
phytopi/templates/datasets.html
Normal file
|
@ -0,0 +1,50 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content%}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h2>{% if content.id %}Dataset{% else %}Datasets{% endif %}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
{% if content.id %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Preview</th>
|
||||
<th scope="col">Date/Time (UTC)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for image in content.data %}
|
||||
<tr>
|
||||
<td><img class="img-fluid img-thumbnail" src="{{url_for('static', filename='data/'~image.filename)}}" alt="{{ image.filename }}"></td>
|
||||
<td>{{ image.datetime_created }}</td>
|
||||
</tr>
|
||||
{% endfor%}
|
||||
</tbody>
|
||||
{% else %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Date/Time (UTC)</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dataset in content.data %}
|
||||
<tr>
|
||||
<th scope="row">{{ dataset.id }}</th>
|
||||
<td>{% if dataset.title %}{{ dataset.title }}{% endif %}</td>
|
||||
<td><a href="{{url_for('datasets')}}/{{ dataset.id }}">{{ dataset.datetime_created }}</a></td>
|
||||
<td><a href="{{url_for('dataset_download', dataset_id=dataset.id)}}">Download</a> | <a href="{{url_for('dataset_delete', dataset_id=dataset.id)}}">Delete</a></td>
|
||||
</tr>
|
||||
{% endfor%}
|
||||
</tbody>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
|
@ -3,13 +3,13 @@
|
|||
{% block content%}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<img class="rounded img-fluid mx-auto d-block" src="{{ url_for('frontend.video_feed') }}">
|
||||
<img class="img-thumbnail img-fluid mx-auto d-block" style="max-width:500px" src="{{ url_for('video_feed') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row mt-1">
|
||||
<div class="col text-center">
|
||||
<button id="start_button" type="button" class="btn btn-primary">Start</button>
|
||||
<button id="stop_button" type="button" class="btn btn-danger">Stop</button>
|
||||
<button id="start_stop_button" type="button" class="btn {% if content.timelapse %} btn-danger">Stop{% else %} btn-primary">Start{% endif %}</button>
|
||||
<button id="settings_button" type="button" class="btn btn-secondary">Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
33
phytopi/templates/settings.html
Normal file
|
@ -0,0 +1,33 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content%}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h2>Datasets</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form action="" method="post" novalidate>
|
||||
{{ form.hidden_tag()}}
|
||||
<div class="form-group">
|
||||
<label for="camera_iso">ISO</label>
|
||||
<input type="number" class="form-control" id="camera_iso" name="camera_iso" aria-describedby="camera_iso_help" value="" min="0">
|
||||
<small id="camera_iso_help" class="form-text text-muted">ISO value for camera</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="camera_fps">Framerate</label>
|
||||
<input type="number" class="form-control" id="camera_fps" name="camera_fps" aria-describedby="camera_fps_help" value="" min="1" max="60">
|
||||
<small id="camera_fps_help" class="form-text text-muted">Frames per second</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-check-label" for="camera_fix_shutter">Fixed exposure time</label>
|
||||
<input type="checkbox" lass="form-check-input" id="camera_fix_shutter" name="camera_fix_shutter" aria-describedby="camera_fix_shutter_help">
|
||||
<small id="camera_fix_shutter_help" class="form-text text-muted">Clamp exposure time to a fixed value after camera warmup</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-check-label" for="camera_fix_wb">Fixed white balance</label>
|
||||
<input type="checkbox" lass="form-check-input" id="camera_fix_wb" name="camera_fix_wb" aria-describedby="camera_fix_wb_help">
|
||||
<small id="camera_fix_wb_help" class="form-text text-muted">Clamp white balance to a fixed value after camera warmup</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save & Apply</button>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,25 +0,0 @@
|
|||
from flask import current_app, Blueprint, render_template, Response
|
||||
|
||||
from phytopi.camera.camera_dummy import Camera
|
||||
|
||||
bp = Blueprint('frontend', __name__, url_prefix='')
|
||||
|
||||
@bp.route('/')
|
||||
@bp.route('/index')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@bp.route('/start', methods=['GET', 'POST'])
|
||||
def start_timelapse():
|
||||
pass
|
||||
|
||||
def gen(camera):
|
||||
while True:
|
||||
frame = camera.get_frame()
|
||||
yield (b'--frame\r\n'
|
||||
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')
|
||||
|
||||
@bp.route('/video_feed')
|
||||
def video_feed():
|
||||
return Response(gen(Camera()),
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame')
|
19
requirements_pi.txt
Normal file
|
@ -0,0 +1,19 @@
|
|||
alembic==1.0.1
|
||||
Click==7.0
|
||||
Flask==1.0.2
|
||||
Flask-Migrate==2.3.0
|
||||
Flask-SQLAlchemy==2.3.2
|
||||
Flask-WTF==0.14.2
|
||||
itsdangerous==1.1.0
|
||||
Jinja2==2.10
|
||||
Mako==1.0.7
|
||||
MarkupSafe==1.0
|
||||
picamera==1.13
|
||||
Pillow==5.3.0
|
||||
python-dateutil==2.7.5
|
||||
python-editor==1.0.3
|
||||
six==1.11.0
|
||||
SQLAlchemy==1.2.12
|
||||
Werkzeug==0.14.1
|
||||
WTForms==2.2.1
|
||||
zipstream==1.1.4
|