Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add exif based image rotation #37

Merged
merged 11 commits into from
Mar 19, 2023
54 changes: 42 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,21 @@ This will start the server with the following default configuration:

On startup the following arguments are supported:

| Argument | Description | Default |
| ------------------------ | --------------------------------------------------------------------------------------------------- | ------------ |
| `-b`, `--bindaddress` | Address where the server will listen for new incoming connections. | `0.0.0.0` |
| `-p`, `--port` | Port where the server will listen for new incoming connections. | `8080` |
| `-r`, `--resolution` | Resolution of the captured frames. This argument expects the format <width>x<height> | `640x480` |
| `-f`, `--fps` | Framerate in frames per second (fps). | `15` |
| `-st`, `--stream_url` | Sets the URL for the mjpeg stream. | `/stream` |
| `-sn`, `--snapshot_url` | Sets the URL for snapshots (single frame of stream). | `/snapshot` |
| `-af`, `--autofocus` | Autofocus mode. Supported modes: `manual`, `continuous` | `continuous` |
| `-l`, `--lensposition` | Set focal distance. 0 for infinite focus, 0.5 for approximate 50cm. Only used with Autofocus manual | `0.0` |
| `-s`, `--autofocusspeed` | Autofocus speed. Supported values: `normal`, `fast`. Only used with Autofocus continuous | `normal` |

| Argument | Description | Default |
|----------------------------|-----------------------------------------------------------------------------------------------------|--------------|
| `-b`, `--bindaddress` | Address where the server will listen for new incoming connections. | `0.0.0.0` |
| `-p`, `--port` | Port where the server will listen for new incoming connections. | `8080` |
| `-r`, `--resolution` | Resolution of the captured frames. This argument expects the format <width>x<height> | `640x480` |
| `-f`, `--fps` | Framerate in frames per second (fps). | `15` |
| `-st`, `--stream_url` | Sets the URL for the mjpeg stream. | `/stream` |
| `-sn`, `--snapshot_url` | Sets the URL for snapshots (single frame of stream). | `/snapshot` |
| `-af`, `--autofocus` | Autofocus mode. Supported modes: `manual`, `continuous` | `continuous` |
| `-l`, `--lensposition` | Set focal distance. 0 for infinite focus, 0.5 for approximate 50cm. Only used with Autofocus manual | `0.0` |
| `-s`, `--autofocusspeed` | Autofocus speed. Supported values: `normal`, `fast`. Only used with Autofocus continuous | `normal` |
| `-ud` `--upsidedown` | Rotate the image by 180° (see below) | |
| `-fh` `--flip_horizontal` | Mirror the image horizontally (see below) | |
| `-fv` `--flip_vertical` | Mirror the image vertically (see below) | |
| `-or` `--orientation_exif` | Set the image orientation using an EXIF header (see below) | |
Starting the server without any argument is the same as

```shell
Expand All @@ -64,6 +67,33 @@ Please note that the maximum recommended resolution is 1920x1080 (16:9).

The absolute maximum resolution is 1920x1920. If you choose a higher resolution spyglass may crash.

### Image Orientation

There are two ways to change the image orientation.

To use the ability of picamera2 to transform the image you can use the following options when starting spyglass:
* `-ud` or `--upsidedown` - Rotate the image by 180°
* `-fh` or `--flip_horizontal` - Mirror the image horizontally
* `-fv` or `--flip_vertical` - Mirror the image vertically

Alternatively you can create an EXIF header to modify the image orientation. Most modern browsers should respect the
this header.

Use the `-or` or `--orientation_exif` option and choose from one of the following orientations
* `h` - Horizontal (normal)
* `mh` - Mirror horizontal
* `r180` - Rotate 180
* `mv` - Mirror vertical
* `mhr270` - Mirror horizontal and rotate 270 CW
* `r90` - Rotate 90 CW
* `mhr90` - Mirror horizontal and rotate 90 CW
* `r270` - Rotate 270 CW

For example to rotate the image 90 degree clockwise you would start spyglass the following way:
```shell
./run.py -or r90
```

## Using Spyglass with Mainsail

If you want to use Spyglass as a webcam source for [Mainsail]() add a webcam with the following configuration:
Expand Down
12 changes: 12 additions & 0 deletions resources/spyglass.conf
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,15 @@ FOCAL_DIST="0.0"
#### NOTE: Autofocus speed. Supported values: normal, fast.
#### Only used with Autofocus continuous
AF_SPEED="normal"

#### EXIF Orientation (STRING:h,mh,r180,mv,mhr270,r90,mhr90,r270)[default: h]
#### NOTE: Set the image orientation using an EXIF header.
#### h - Horizontal (normal)
#### mh - Mirror horizontal
#### r180 - Rotate 180
#### mv - Mirror vertical
#### mhr270 - Mirror horizontal and rotate 270 CW
#### r90 - Rotate 90 CW
#### mhr90 - Mirror horizontal and rotate 90 CW
#### r270 - Rotate 270 CW
ORIENTATION_EXIF="h"
3 changes: 2 additions & 1 deletion scripts/spyglass
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ run_spyglass() {
--snapshot_url "${SNAPSHOT_URL:-\/snapshot}" \
--autofocus "${AUTO_FOCUS:-continuous}" \
--lensposition "${FOCAL_DIST:-0.0}" \
--autofocusspeed "${AF_SPEED:-normal}"
--autofocusspeed "${AF_SPEED:-normal}" \
--orientation_exif "${ORIENTATION_EXIF:-h}"
}

#### MAIN
Expand Down
34 changes: 28 additions & 6 deletions spyglass/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from picamera2.encoders import MJPEGEncoder
from picamera2.outputs import FileOutput

from spyglass.exif import option_to_exif_orientation
from spyglass.__version__ import __version__
from spyglass.camera import init_camera
from spyglass.server import StreamingOutput
Expand Down Expand Up @@ -39,6 +40,7 @@ def main(args=None):
width, height = split_resolution(parsed_args.resolution)
stream_url = parsed_args.stream_url
snapshot_url = parsed_args.snapshot_url
orientation_exif = parsed_args.orientation_exif
picam2 = init_camera(
width,
height,
Expand All @@ -54,7 +56,7 @@ def main(args=None):
picam2.start_recording(MJPEGEncoder(), FileOutput(output))

try:
run_server(bind_address, port, output, stream_url, snapshot_url)
run_server(bind_address, port, output, stream_url, snapshot_url, orientation_exif)
finally:
picam2.stop_recording()

Expand All @@ -68,20 +70,29 @@ def resolution_type(arg_value, pat=re.compile(r"^\d+x\d+$")):
return arg_value


def orientation_type(arg_value):
if arg_value in option_to_exif_orientation:
return option_to_exif_orientation[arg_value]
else:
raise argparse.ArgumentTypeError(f"invalid value: unknown orientation {arg_value}.")


def parse_autofocus(arg_value):
if arg_value == 'manual':
return libcamera.controls.AfModeEnum.Manual
elif arg_value == 'continuous':
return libcamera.controls.AfModeEnum.Continuous
raise argparse.ArgumentTypeError("invalid value: manual or continuous expected.")
else:
raise argparse.ArgumentTypeError("invalid value: manual or continuous expected.")


def parse_autofocus_speed(arg_value):
if arg_value == 'normal':
return libcamera.controls.AfSpeedEnum.Normal
elif arg_value == 'fast':
return libcamera.controls.AfSpeedEnum.Fast
raise argparse.ArgumentTypeError("invalid value: normal or fast expected.")
else:
raise argparse.ArgumentTypeError("invalid value: normal or fast expected.")


def split_resolution(res):
Expand Down Expand Up @@ -129,11 +140,22 @@ def get_parser():
parser.add_argument('-s', '--autofocusspeed', type=str, default='normal', choices=['normal', 'fast'],
help='Autofocus speed. Only used with Autofocus continuous')
parser.add_argument('-ud', '--upsidedown', action='store_true',
help='Rotate the image by 180°')
help='Rotate the image by 180° (sensor level)')
parser.add_argument('-fh', '--flip_horizontal', action='store_true',
help='Mirror the image horizontally')
help='Mirror the image horizontally (sensor level)')
parser.add_argument('-fv', '--flip_vertical', action='store_true',
help='Mirror the image vertically')
help='Mirror the image vertically (sensor level)')
parser.add_argument('-or', '--orientation_exif', type=orientation_type, default='h',
help='Set the image orientation using an EXIF header:\n'
' h - Horizontal (normal)\n'
' mh - Mirror horizontal\n'
' r180 - Rotate 180\n'
' mv - Mirror vertical\n'
' mhr270 - Mirror horizontal and rotate 270 CW\n'
' r90 - Rotate 90 CW\n'
' mhr90 - Mirror horizontal and rotate 90 CW\n'
' r270 - Rotate 270 CW'
)
return parser

# endregion cli args
47 changes: 47 additions & 0 deletions spyglass/exif.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
def create_exif_header(orientation: int):
if orientation <= 0:
return None

return b''.join([
b'\xFF\xD8', # Start of Image (SOI) marker
b'\xFF\xE1', # APP1 marker
b'\x00\x62', # Length of APP 1 segment (98 bytes)
b'\x45\x78\x69\x66', # EXIF identifier ("Exif" in ASCII)
b'\x00\x00', # Padding bytes
# TIFF header (with big-endian indicator)
b'\x4D\x4D', # Big endian
b'\x00\x2A', # TIFF magic number
b'\x00\x00\x00\x08', # Offset to first IFD (8 bytes)
# Image File Directory (IFD)
b'\x00\x05', # Number of entries in the IFD (5)
# v-- Orientation tag (tag number = 0x0112, type = USHORT, count = 1)
b'\x01\x12', b'\x00\x03', b'\x00\x00\x00\x01',
b'\x00', orientation.to_bytes(1, 'big'), b'\x00\x00', # Tag data
# v-- XResolution tag (tag number = 0x011A, type = UNSIGNED RATIONAL, count = 1)
b'\x01\x1A', b'\x00\x05', b'\x00\x00\x00\x01',
b'\x00\x00\x00\x4A', # Tag data (address)
# v-- YResolution tag (tag number = 0x011B, type = UNSIGNED RATIONAL, count = 1)
b'\x01\x1B', b'\x00\x05', b'\x00\x00\x00\x01',
b'\x00\x00\x00\x52', # Tag data (address)
# v-- ResolutionUnit tag (tag number = 0x0128, type = USHORT, count = 1)
b'\x01\x28', b'\x00\x03', b'\x00\x00\x00\x01',
b'\x00\x02\x00\x00', # 2 - Inch
# v-- YCbCrPositioning tag (tag number = 0x0213, type = USHORT, count = 1)
b'\x02\x13', b'\x00\x03', b'\x00\x00\x00\x01',
b'\x00\x01\x00\x00', # center of pixel array
b'\x00\x00\x00\x00', # Offset to next IFD 0
b'\x00\x00\x00\x48\x00\x00\x00\x01', # XResolution value
b'\x00\x00\x00\x48\x00\x00\x00\x01' # YResolution value
])


option_to_exif_orientation = {
'h': 1,
'mh': 2,
'r180': 3,
'mv': 4,
'mhr270': 5,
'r90': 6,
'mhr90': 7,
'r270': 8
}
39 changes: 28 additions & 11 deletions spyglass/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import socketserver
from http import server
from threading import Condition

from spyglass.exif import create_exif_header
from . import logger


Expand All @@ -22,8 +24,10 @@ class StreamingServer(socketserver.ThreadingMixIn, server.HTTPServer):
daemon_threads = True


def run_server(bind_address, port, output, stream_url='/stream', snapshot_url='/snapshot'):
class StreamingHandler(server.BaseHTTPRequestHandler):
def run_server(bind_address, port, output, stream_url='/stream', snapshot_url='/snapshot', orientation_exif=0):
exif_header = create_exif_header(orientation_exif)

class StreamingHandler(server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == stream_url:
self.start_streaming()
Expand All @@ -44,10 +48,17 @@ def start_streaming(self):
output.condition.wait()
frame = output.frame
self.wfile.write(b'--FRAME\r\n')
self.send_jpeg_content_headers(frame)
self.end_headers()
self.wfile.write(frame)
self.wfile.write(b'\r\n')
if exif_header is None:
self.send_jpeg_content_headers(frame)
self.end_headers()
self.wfile.write(frame)
self.wfile.write(b'\r\n')
else:
self.send_jpeg_content_headers(frame, len(exif_header) - 2)
self.end_headers()
self.wfile.write(exif_header)
self.wfile.write(frame[2:])
self.wfile.write(b'\r\n')
except Exception as e:
logging.warning('Removed streaming client %s: %s', self.client_address, str(e))

Expand All @@ -58,9 +69,15 @@ def send_snapshot(self):
with output.condition:
output.condition.wait()
frame = output.frame
self.send_jpeg_content_headers(frame)
self.end_headers()
self.wfile.write(frame)
if orientation_exif <= 0:
self.send_jpeg_content_headers(frame)
self.end_headers()
self.wfile.write(frame)
else:
self.send_jpeg_content_headers(frame, len(exif_header) - 2)
self.end_headers()
self.wfile.write(exif_header)
self.wfile.write(frame[2:])
except Exception as e:
logging.warning(
'Removed client %s: %s',
Expand All @@ -71,9 +88,9 @@ def send_default_headers(self):
self.send_header('Cache-Control', 'no-cache, private')
self.send_header('Pragma', 'no-cache')

def send_jpeg_content_headers(self, frame):
def send_jpeg_content_headers(self, frame, extra_len=0):
self.send_header('Content-Type', 'image/jpeg')
self.send_header('Content-Length', len(frame))
self.send_header('Content-Length', str(len(frame) + extra_len))

logger.info('Server listening on %s:%d', bind_address, port)
logger.info('Streaming endpoint: %s', stream_url)
Expand Down
Loading