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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@

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

# PhotometricInterpretation values: 2 = RGB, others (0, 1, 3) are grayscale or
# palette and are treated as B&W for our display purposes.
_PHOTOMETRIC_RGB = 2


def _derive_dpi(x_resolution: Any) -> int | None:
"""Compute integer DPI from a TIFF ``XResolution`` rational tuple."""
if not isinstance(x_resolution, (list, tuple)) or len(x_resolution) != 2:
return None
numerator, denominator = x_resolution
if not isinstance(numerator, (int, float)) or not isinstance(denominator, (int, float)):
return None
if denominator == 0:
return None
return int(numerator / denominator)


def _derive_color_mode(samples_per_pixel: Any, photometric_interpretation: Any) -> str | None:
"""Infer ``rgb`` vs ``bw`` from TIFF tags, preferring SamplesPerPixel."""
if isinstance(samples_per_pixel, (list, tuple)):
samples_per_pixel = samples_per_pixel[0] if samples_per_pixel else None
if isinstance(samples_per_pixel, int):
return "rgb" if samples_per_pixel >= 3 else "bw"
if isinstance(photometric_interpretation, int):
return "rgb" if photometric_interpretation == _PHOTOMETRIC_RGB else "bw"
return None


_METADATA_TAG_NAMES = {
"ImageWidth",
"ImageLength",
Expand Down Expand Up @@ -66,7 +94,16 @@ def export_jpg(self) -> Path:
return jpg_path

def parse_metadata(self) -> dict[str, Any]:
"""Extract TIFF tags as a flat string-keyed dict."""
"""Extract TIFF tags as a flat string-keyed dict.

In addition to the raw TIFF tags, this also emits two derived
scalar fields used by the web UI for filtering and display:

- ``dpi``: integer DPI computed from ``XResolution`` (a (numerator,
denominator) rational). For Epson V700 scans this is 300 or 600.
- ``color_mode``: ``"rgb"`` or ``"bw"``, inferred from
``SamplesPerPixel`` (preferred) or ``PhotometricInterpretation``.
"""
metadata: dict[str, Any] = {}
with tifffile.TiffFile(self.path) as tif:
page = tif.pages.first
Expand All @@ -80,6 +117,18 @@ def parse_metadata(self) -> dict[str, Any]:
h, w = self.intensities.shape[:2]
metadata["OriginalHeight"] = int(h)
metadata["OriginalWidth"] = int(w)

dpi = _derive_dpi(metadata.get("XResolution"))
if dpi is not None:
metadata["dpi"] = dpi

color_mode = _derive_color_mode(
metadata.get("SamplesPerPixel"),
metadata.get("PhotometricInterpretation"),
)
if color_mode is not None:
metadata["color_mode"] = color_mode

return metadata

# ------------------------------------------------------------------
Expand Down
67 changes: 67 additions & 0 deletions lambda/tests/epson_v700_scanner/test_image_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from data_hub_lambda.epson_v700_scanner.image_processing import (
MAX_DIMENSION,
TIFFToJPEGConverter,
_derive_color_mode,
_derive_dpi,
)


Expand Down Expand Up @@ -119,6 +121,71 @@ def test_returns_standard_tiff_tags(self, tmp_path: Path) -> None:
assert "BitsPerSample" in metadata


class TestDeriveDpi:
def test_returns_300_dpi(self) -> None:
# 300 dpi = 300 * 2^22 / 2^22; the V700 stores it as 1258291200/4194304
assert _derive_dpi([1258291200, 4194304]) == 300

def test_returns_600_dpi(self) -> None:
assert _derive_dpi([1258291200, 2097152]) == 600

def test_handles_tuple(self) -> None:
assert _derive_dpi((600, 1)) == 600

def test_returns_none_for_missing(self) -> None:
assert _derive_dpi(None) is None

def test_returns_none_for_zero_denominator(self) -> None:
assert _derive_dpi([300, 0]) is None

def test_returns_none_for_malformed(self) -> None:
assert _derive_dpi([300]) is None
assert _derive_dpi("not a list") is None


class TestDeriveColorMode:
def test_samples_per_pixel_3_is_rgb(self) -> None:
assert _derive_color_mode(3, None) == "rgb"

def test_samples_per_pixel_1_is_bw(self) -> None:
assert _derive_color_mode(1, None) == "bw"

def test_falls_back_to_photometric_rgb(self) -> None:
assert _derive_color_mode(None, 2) == "rgb"

def test_falls_back_to_photometric_bw(self) -> None:
assert _derive_color_mode(None, 1) == "bw"

def test_returns_none_when_both_missing(self) -> None:
assert _derive_color_mode(None, None) is None


class TestParseMetadataDerivedFields:
def test_emits_dpi_and_rgb_color_mode(self, tmp_path: Path) -> None:
img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
tif_path = tmp_path / "scan.tif"
tifffile.imwrite(str(tif_path), img, resolution=(300, 300))

converter = TIFFToJPEGConverter(tif_path)
converter.load()
metadata = converter.parse_metadata()

assert metadata["dpi"] == 300
assert metadata["color_mode"] == "rgb"

def test_emits_bw_color_mode_for_grayscale(self, tmp_path: Path) -> None:
img = np.random.randint(0, 255, (100, 100), dtype=np.uint8)
tif_path = tmp_path / "gray.tif"
tifffile.imwrite(str(tif_path), img, resolution=(600, 600))

converter = TIFFToJPEGConverter(tif_path)
converter.load()
metadata = converter.parse_metadata()

assert metadata["dpi"] == 600
assert metadata["color_mode"] == "bw"


class TestValidation:
def test_rejects_missing_file(self, tmp_path: Path) -> None:
converter = TIFFToJPEGConverter(tmp_path / "nonexistent.tif")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export async function POST(request: NextRequest, { params }: RouteContext) {
const origin = new URL(request.url).origin;
await sendSlackMessage(
`*${instrument.displayName}*\n` +
`New run \`${runId}\` created (source: ${source}).\n` +
`New instrument run reported: \`${runId}\`.\n` +
`<${origin}/instruments/${instrumentId}/runs/${encodeURIComponent(runId)}|View in Data Hub>`
);
}
Expand Down
14 changes: 14 additions & 0 deletions web-app/app/instruments/[instrumentId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type RunRow,
} from "@/components/instruments/runs-table";
import { DefaultRunsTable } from "@/components/instruments/runs-table/default-runs-table";
import { EpsonScannerRunsTable } from "@/components/instruments/runs-table/epson-scanner-runs-table";
import { GelDocRunsTable } from "@/components/instruments/runs-table/gel-doc-runs-table";
import { HinaRunsTable } from "@/components/instruments/runs-table/hina-runs-table";
import { PlateReaderRunsTable } from "@/components/instruments/runs-table/plate-reader-runs-table";
Expand Down Expand Up @@ -90,6 +91,15 @@ function renderRunsTableVariant(
ranByOptions={ranByOptions}
/>
);
case "epson_v700_scanner":
return (
<EpsonScannerRunsTable
data={data}
instrumentId={instrumentId}
filterOptions={filterOptions.options}
ranByOptions={ranByOptions}
/>
);
case "default":
return (
<DefaultRunsTable
Expand Down Expand Up @@ -134,6 +144,8 @@ export default async function InstrumentDetailPage({
hinaChannel: filters.hina_channel ?? undefined,
hinaDimension: filters.hina_dimension ?? undefined,
hinaSize: filters.hina_size ?? undefined,
dpi: filters.dpi ?? undefined,
colorMode: filters.color_mode ?? undefined,
ranBy: filters.ran_by ?? undefined,
}),
]);
Expand Down Expand Up @@ -163,6 +175,8 @@ export default async function InstrumentDetailPage({
filters.hina_channel !== null ||
filters.hina_dimension !== null ||
filters.hina_size !== null ||
filters.dpi !== null ||
filters.color_mode !== null ||
filters.ran_by !== null;

const currentUserId = session.user?.id ?? null;
Expand Down
1 change: 1 addition & 0 deletions web-app/components/instruments/edit-instrument-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const TYPE_LABELS: Record<string, string> = {
qpcr: "qPCR",
tape_station: "TapeStation",
hina_microscope: "Hina Microscope",
epson_v700_scanner: "Epson V700 Scanner",
};

const INSTRUMENT_TYPE_OPTIONS = VALID_INSTRUMENT_TYPES.map((value) => ({
Expand Down
172 changes: 172 additions & 0 deletions web-app/components/instruments/runs-table/epson-scanner-runs-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { RelativeTime } from "@/components/dashboard/relative-time";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { EpsonScannerFilterOptions } from "@/lib/api/instrument-runs";
import {
COLOR_MODE_COLORS,
DPI_COLORS,
formatColorMode,
} from "@/lib/instrument-colors";
import { runRowToRef } from "@/lib/runs/row-actions";
import { cn, formatBytes } from "@/lib/utils";

import type { RunRow } from ".";
import { FilterableColumnHeader } from "./filterable-column-header";
import { MetadataFieldBadge, getMetadataField } from "./metadata-utils";
import { RanByCell } from "./ran-by-cell";
import { RawFileColumnHeader } from "./raw-file-column-header";
import { RunIdLabel } from "./run-id-label";
import { RunRowActions } from "./run-row-actions";
import { RunSelectAllCheckbox, RunSelectCheckbox } from "./run-select-checkbox";
import type { RunRef } from "./run-selection-provider";
import { RunStatusIcon } from "./run-status-icon";

// DPI is a numeric scalar; sort ascending so "300" < "600" rather than
// lexicographic order.
function sortDpiOptions(dpis: string[]): string[] {
return [...dpis].sort((a, b) => Number(a) - Number(b));
}

function colorModeOption(value: string): { value: string; label: string } {
return { value, label: formatColorMode(value) };
}

export function EpsonScannerRunsTable({
data,
instrumentId,
filterOptions,
ranByOptions,
}: {
data: RunRow[];
instrumentId: string;
filterOptions: EpsonScannerFilterOptions;
ranByOptions: { value: string; label: string }[];
}) {
const runRefs: RunRef[] = data.map(runRowToRef);
const dpiOptions = sortDpiOptions(filterOptions.dpis);
const colorModeOptions = filterOptions.colorModes.map(colorModeOption);

return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<RunSelectAllCheckbox refs={runRefs} />
</TableHead>
<TableHead>Run ID</TableHead>
<TableHead>
<RawFileColumnHeader label="Files" />
</TableHead>
<TableHead className="text-right">
<RawFileColumnHeader label="Size" />
</TableHead>
<TableHead>
<FilterableColumnHeader
label="DPI"
paramKey="dpi"
options={dpiOptions}
/>
</TableHead>
<TableHead>
<FilterableColumnHeader
label="Color Mode"
paramKey="color_mode"
options={colorModeOptions}
/>
</TableHead>
<TableHead>
<FilterableColumnHeader
label="Ran By"
paramKey="ran_by"
options={ranByOptions}
/>
</TableHead>
<TableHead className="text-right">Created</TableHead>
<TableHead className="w-[132px]">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => {
const isDeleted = row.deleted_at !== null;
const dpi = getMetadataField(row.metadata, "dpi");
const colorMode = getMetadataField(row.metadata, "color_mode");
return (
<TableRow
key={row.id}
className={cn("group", isDeleted && "opacity-50")}
>
<TableCell>
<RunSelectCheckbox runRef={runRowToRef(row)} />
</TableCell>
<TableCell>
<div className="flex items-center gap-2.5">
<RunStatusIcon
fileCount={row.file_count}
filesCompleted={row.files_completed}
filesFailed={row.files_failed}
filesPendingUpload={row.files_pending_upload}
filesUploaded={row.files_uploaded}
filesProcessing={row.files_processing}
errorMessages={row.error_messages}
/>
<RunIdLabel
runId={row.run_id}
href={`/instruments/${instrumentId}/runs/${encodeURIComponent(row.run_id)}`}
isDeleted={isDeleted}
/>
{isDeleted && (
<Badge variant="outline" className="ml-1.5 font-normal">
deleted
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-sm tabular-nums">
{row.file_count}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatBytes(row.total_size_bytes)}
</TableCell>
<TableCell>
<MetadataFieldBadge
value={dpi}
colorClass={dpi ? DPI_COLORS[dpi] : undefined}
/>
</TableCell>
<TableCell>
<MetadataFieldBadge
value={colorMode ? formatColorMode(colorMode) : null}
colorClass={
colorMode ? COLOR_MODE_COLORS[colorMode] : undefined
}
/>
</TableCell>
<TableCell>
<RanByCell
instrumentId={row.instrument_id}
runId={row.run_id}
attributions={row.attributions}
/>
</TableCell>
<TableCell className="text-right">
<RelativeTime date={row.created_at.toISOString()} />
</TableCell>
<TableCell className="py-1">
<RunRowActions row={row} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
Loading
Loading