Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/lambda.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ See [Run archives](run-archives.md) for the full flow, S3 bucket layout, cache s
| Akta FPLC | `akta_fplc` | `akta-fplc` |
| Azure 600 Gel Doc | `azure_600_gel_doc` | `azure-600-gel-doc` |
| Azure Cielo qPCR | `azure_cielo_qpcr` | `azure-cielo-qpcr` |
| Epson V700 Scanner | `epson_v700_scanner` | `epson-v700-scanner` |
| Hina Microscope | `hina_microscope` | `hina-microscope` |
| SpectraMax iD3 Plate Reader | `spectramax_plate_reader` | `spectramax-id3-plate-reader` |
| SpectraMax iD5 Plate Reader | `spectramax_plate_reader` | `spectramax-id5-plate-reader` |
Expand Down Expand Up @@ -78,6 +79,7 @@ Available commands:

| Command | Description |
| --- | --- |
| `epson-scanner` | Process an Epson V700 Scanner TIFF (resized JPEG preview + metadata) |
| `gel-doc` | Process an Azure 600 Gel Doc TIFF (contrast-enhanced PNG + metadata) |
| `hina` | Convert a Hina microscope ND2 file to a JPG overlay + metadata |
| `qpcr` | Parse dye channels from an Azure Cielo qPCR Cq Values CSV |
Expand Down
18 changes: 18 additions & 0 deletions infra/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,24 @@ Resources:
# with `+` — this matches keys ending in `_Cq Values.csv`.
Value: _Cq+Values.csv
Function: !GetAtt DataHubFunction.Arn
- Event: s3:ObjectCreated:*
Filter:
S3Key:
Rules:
- Name: prefix
Value: epson-v700-scanner/
- Name: suffix
Value: .tif
Function: !GetAtt DataHubFunction.Arn
- Event: s3:ObjectCreated:*
Filter:
S3Key:
Rules:
- Name: prefix
Value: epson-v700-scanner/
- Name: suffix
Value: .tiff
Function: !GetAtt DataHubFunction.Arn
- Event: s3:ObjectCreated:*
Filter:
S3Key:
Expand Down
37 changes: 37 additions & 0 deletions lambda/src/data_hub_lambda/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,43 @@ def hina(file: Path, output_dir: Path | None) -> None:
click.echo(json.dumps(metadata, indent=2))


# ---------------------------------------------------------------------------
# Epson V700 Scanner
# ---------------------------------------------------------------------------


@cli.command("epson-scanner")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--output-dir",
type=click.Path(file_okay=False, path_type=Path),
default=None,
help="Directory for the exported JPG (default: same directory as FILE).",
)
def epson_scanner(file: Path, output_dir: Path | None) -> None:
"""Process an Epson V700 Scanner TIFF file.

Resizes the high-resolution scan to a web-friendly JPEG preview and
extracts TIFF metadata.
"""
from data_hub_lambda.epson_v700_scanner.image_processing import TIFFToJPEGConverter

converter = TIFFToJPEGConverter(file)
converter.load()
jpg_path = converter.export_jpg()

if output_dir is not None:
output_dir.mkdir(parents=True, exist_ok=True)
dest = output_dir / jpg_path.name
shutil.move(str(jpg_path), str(dest))
jpg_path = dest

click.echo(f"Exported JPG: {jpg_path}")

metadata = converter.parse_metadata()
click.echo(json.dumps(metadata, indent=2))


# ---------------------------------------------------------------------------
# Azure Cielo qPCR
# ---------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions lambda/src/data_hub_lambda/epson_v700_scanner/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from data_hub_lambda.epson_v700_scanner.process_file import (
process_file, # noqa: F401
)
124 changes: 124 additions & 0 deletions lambda/src/data_hub_lambda/epson_v700_scanner/image_processing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any

import numpy as np
import skimage as ski
import tifffile
from numpy.typing import NDArray

logger = logging.getLogger(__name__)

JPEG_QUALITY = 85

MAX_DIMENSION = 1000

_TIFF_SUFFIXES = {".tif", ".tiff"}

_METADATA_TAG_NAMES = {
"ImageWidth",
"ImageLength",
"BitsPerSample",
"Compression",
"PhotometricInterpretation",
"SamplesPerPixel",
"XResolution",
"YResolution",
"ResolutionUnit",
"Software",
"DateTime",
"Artist",
"Make",
"Model",
"ImageDescription",
}


class TIFFToJPEGConverter:
"""Converts high-resolution TIFF scans to resized JPEG images."""

def __init__(self, path: Path) -> None:
self.path = path
self._intensities: NDArray[Any] | None = None

def load(self) -> None:
if not self.path.exists():
raise FileNotFoundError(f"TIFF file not found: {self.path}")
if self.path.suffix.lower() not in _TIFF_SUFFIXES:
raise ValueError(f"Expected TIFF file (.tif/.tiff), got: {self.path.suffix}")

self._intensities = tifffile.imread(self.path)

@property
def intensities(self) -> NDArray[Any]:
if self._intensities is None:
raise RuntimeError("Call load() first.")
return self._intensities

def export_jpg(self) -> Path:
"""Resize the loaded TIFF and write a JPEG next to the source file."""
img = self._to_rgb_uint8(self.intensities)
img = self._resize(img)

jpg_path = self.path.parent / f"{self.path.stem}.jpg"
ski.io.imsave(str(jpg_path), img, quality=JPEG_QUALITY)
return jpg_path

def parse_metadata(self) -> dict[str, Any]:
"""Extract TIFF tags as a flat string-keyed dict."""
metadata: dict[str, Any] = {}
with tifffile.TiffFile(self.path) as tif:
page = tif.pages.first
for tag in page.tags.values():
if tag.name in _METADATA_TAG_NAMES:
value = tag.value
if isinstance(value, tuple):
value = list(value)
metadata[tag.name] = value

h, w = self.intensities.shape[:2]
metadata["OriginalHeight"] = int(h)
metadata["OriginalWidth"] = int(w)
return metadata

# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------

@staticmethod
def _to_rgb_uint8(img: NDArray[Any]) -> NDArray[np.uint8]:
"""Normalize to 8-bit RGB regardless of input dtype/channels."""
if img.dtype != np.uint8:
img = ski.util.img_as_ubyte(ski.exposure.rescale_intensity(img))

if img.ndim == 2:
img = ski.color.gray2rgb(img)
elif img.ndim == 3 and img.shape[2] == 4:
img = ski.color.rgba2rgb(img)
img = ski.util.img_as_ubyte(img)

return img # type: ignore[return-value]

@staticmethod
def _resize(img: NDArray[np.uint8]) -> NDArray[np.uint8]:
"""Downsample so the longest edge is at most MAX_DIMENSION pixels."""
h, w = img.shape[:2]
if max(h, w) <= MAX_DIMENSION:
return img

scale = MAX_DIMENSION / max(h, w)
new_h = int(h * scale)
new_w = int(w * scale)

resized: NDArray[np.uint8] = np.asarray(
ski.transform.resize(
img,
(new_h, new_w),
anti_aliasing=True,
preserve_range=True,
),
dtype=np.uint8,
)

return resized
90 changes: 90 additions & 0 deletions lambda/src/data_hub_lambda/epson_v700_scanner/process_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from __future__ import annotations
import logging

from data_hub_lambda.api_client import get_client
from data_hub_lambda.constants import DATA_HUB_WEB_URL
from data_hub_lambda.epson_v700_scanner.image_processing import TIFFToJPEGConverter
from data_hub_shared import s3_utils
from data_hub_shared.config import config
from data_hub_shared.enums import Instrument

logger = logging.getLogger(__name__)

INSTRUMENT_ID = Instrument.EPSON_V700_SCANNER.value


def process_file(run_id: str, filename: str) -> str:
"""Process a single Epson V700 Scanner file through the Data Hub API.

Downloads the raw TIFF, resizes it to a web-friendly JPEG, uploads the
JPEG to the processed S3 bucket, extracts TIFF metadata, and registers
both files via the API.

Args:
run_id: The run ID.
filename: The original filename (e.g. ``scan_001.tif``).

Returns:
The web app URL for the instrument run.
"""
logger.info("Processing Epson V700 Scanner file: %s (run: %s)", filename, run_id)

client = get_client()
s3_bucket = config.AWS_S3_RAW_DATA_BUCKET
s3_key = f"{INSTRUMENT_ID}/{run_id}/{filename}"

client.ensure_run(INSTRUMENT_ID, run_id)

file_record = client.create_file(
instrument_id=INSTRUMENT_ID,
run_id=run_id,
s3_bucket=s3_bucket or "",
s3_key=s3_key,
filename=filename,
)
file_id = file_record.id

try:
client.update_file(file_id, status="processing")

raw_data_dir = config.LOCAL_RAW_DATA_DIRPATH / INSTRUMENT_ID / run_id
local_file_path = raw_data_dir / filename
s3_utils.download_file(f"s3://{s3_bucket}/{s3_key}", local_file_path)
logger.info("Downloaded %s to %s", filename, local_file_path)

converter = TIFFToJPEGConverter(local_file_path)
converter.load()
jpg_file_path = converter.export_jpg()

processed_bucket = config.AWS_S3_PROCESSED_DATA_BUCKET
jpg_s3_key = f"{INSTRUMENT_ID}/{run_id}/{jpg_file_path.name}"
s3_utils.upload_file(jpg_file_path, f"s3://{processed_bucket}/{jpg_s3_key}")
logger.info("Uploaded processed image to s3://%s/%s", processed_bucket, jpg_s3_key)

processed_file = client.create_file(
instrument_id=INSTRUMENT_ID,
run_id=run_id,
s3_bucket=processed_bucket or "",
s3_key=jpg_s3_key,
filename=jpg_file_path.name,
category="processed",
)
client.update_file(
processed_file.id,
size_bytes=jpg_file_path.stat().st_size,
content_type="image/jpeg",
)

metadata = converter.parse_metadata()
logger.info("Parsed metadata: %s", metadata)

client.update_run(INSTRUMENT_ID, run_id, metadata=metadata)
client.update_file(file_id, status="completed")
logger.info("File %s marked as completed.", filename)

except Exception as e:
logger.error("Error processing file: %s", e)
client.update_file(file_id, status="failed", error_message=str(e))
raise

return f"{DATA_HUB_WEB_URL}/instruments/{INSTRUMENT_ID}/runs/{run_id}"
7 changes: 7 additions & 0 deletions lambda/src/data_hub_lambda/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
archive_builder,
azure_600_gel_doc,
azure_cielo_qpcr,
epson_v700_scanner,
hina_microscope,
spectramax_plate_reader,
)
Expand Down Expand Up @@ -391,6 +392,12 @@ def lambda_handler(event: dict[str, Any], context: Context) -> dict[str, Any] |
filename=event_info.filename,
)

elif instrument_id == Instrument.EPSON_V700_SCANNER.value:
result_url = epson_v700_scanner.process_file(
run_id=event_info.run_id,
filename=event_info.filename,
)

elif instrument_id == Instrument.HINA_MICROSCOPE.value:
result_url = hina_microscope.process_file(
run_id=event_info.run_id,
Expand Down
Empty file.
Loading
Loading